@retrivora-ai/rag-engine 1.8.4 → 1.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retrivora-ai/rag-engine",
3
- "version": "1.8.4",
3
+ "version": "1.8.5",
4
4
  "description": "Retrivora AI is a plug-and-play AI engine for RAG chat experiences — generic vector DB + LLM provider, embeddable or standalone.",
5
5
  "author": "Abhinav Alkuchi",
6
6
  "license": "MIT",
@@ -2,7 +2,104 @@
2
2
 
3
3
  @custom-variant dark (&:where(.dark, .dark *));
4
4
 
5
+ @theme {
6
+ --animate-shapeshift: shapeshift 5s ease-in-out infinite forwards;
7
+ --animate-gradientMove: gradientMove 3.5s ease-in-out infinite alternate;
8
+
9
+ @keyframes shapeshift {
10
+ 0% { clip-path: var(--circle); rotate: 0turn; }
11
+ 25% { clip-path: var(--flower); }
12
+ 50% { clip-path: var(--cylinder); }
13
+ 75% { clip-path: var(--hexagon); }
14
+ 100% { clip-path: var(--circle); rotate: 1turn; }
15
+ }
16
+
17
+ @keyframes gradientMove {
18
+ 0% { translate: 0 0; }
19
+ 100% { translate: -75% -75%; }
20
+ }
21
+ }
5
22
 
23
+ @layer base {
24
+ :root {
25
+ --star: shape(
26
+ evenodd from 50% 24.787%,
27
+ curve by 7.143% 18.016% with 0% 0% / 2.9725% 13.814%,
28
+ curve by 17.882% 7.197% with 4.171% 4.2025% / 17.882% 7.197%,
29
+ curve by -17.882% 8.6765% with 0% 0% / -13.711% 4.474%,
30
+ curve by -7.143% 16.5365% with -4.1705% 4.202% / -7.143% 16.5365%,
31
+ curve by -8.6115% -16.5365% with 0% 0% / -4.441% -12.3345%,
32
+ curve by -16.4135% -8.6765% with -4.171% -4.2025% / -16.4135% -8.6765%,
33
+ curve by 16.4135% -7.197% with 0% 0% / 12.2425% -2.9945%,
34
+ curve by 8.6115% -18.016% with 4.1705% -4.202% / 8.6115% -18.016%,
35
+ close
36
+ );
37
+ --flower: shape(
38
+ evenodd from 17.9665% 82.0335%,
39
+ curve by -12.349% -32.0335% with -13.239% -5.129% / -18.021% -15.402%,
40
+ curve by -0.0275% -22.203% with -3.1825% -9.331% / -3.074% -16.6605%,
41
+ curve by 12.3765% -9.8305% with 2.3835% -4.3365% / 6.565% -7.579%,
42
+ curve by 32.0335% -12.349% with 5.129% -13.239% / 15.402% -18.021%,
43
+ curve by 20.4535% -0.8665% with 8.3805% -2.858% / 15.1465% -3.062%,
44
+ curve by 11.58% 13.2155% with 5.225% 2.161% / 9.0355% 6.6475%,
45
+ curve by 12.349% 32.0335% with 13.239% 5.129% / 18.021% 15.402%,
46
+ curve by 0.5715% 21.1275% with 2.9805% 8.7395% / 3.0745% 15.723%,
47
+ curve by -12.9205% 10.906% with -2.26% 4.88% / -6.638% 8.472%,
48
+ curve by -32.0335% 12.349% with -5.129% 13.239% / -15.402% 18.021%,
49
+ curve by -21.1215% 0.5745% with -8.736% 2.9795% / -15.718% 3.0745%,
50
+ curve by -10.912% -12.9235% with -4.883% -2.2595% / -8.477% -6.6385%,
51
+ close
52
+ );
53
+ --hexagon: shape(
54
+ evenodd from 6.47% 67.001%,
55
+ curve by 0% -34.002% with -1.1735% -7.7% / -1.1735% -26.302%,
56
+ curve by 7.0415% -12.1965% with 0.7075% -4.641% / 3.3765% -9.2635%,
57
+ curve by 29.447% -17.001% with 6.0815% -4.8665% / 22.192% -14.1675%,
58
+ curve by 14.083% 0% with 4.3725% -1.708% / 9.7105% -1.708%,
59
+ curve by 29.447% 17.001% with 7.255% 2.8335% / 23.3655% 12.1345%,
60
+ curve by 7.0415% 12.1965% with 3.665% 2.933% / 6.334% 7.5555%,
61
+ curve by 0% 34.002% with 1.1735% 7.7% / 1.1735% 26.302%,
62
+ curve by -7.0415% 12.1965% with -0.7075% 4.641% / -3.3765% 9.2635%,
63
+ curve by -29.447% 17.001% with -6.0815% 4.8665% / -22.192% 14.1675%,
64
+ curve by -14.083% 0% with -4.3725% 1.708% / -9.7105% 1.708%,
65
+ curve by -29.447% -17.001% with -7.255% -2.8335% / -23.3655% -12.1345%,
66
+ curve by -7.0415% -12.1965% with -3.665% -2.933% / -6.334% -7.5555%,
67
+ close
68
+ );
69
+ --cylinder: shape(
70
+ evenodd from 10.5845% 59.7305%,
71
+ curve by 0% -19.461% with -0.113% -1.7525% / -0.11% -18.14%,
72
+ curve by 10.098% -26.213% with 0.837% -10.0375% / 3.821% -19.2625%,
73
+ curve by 29.3175% -13.0215% with 7.2175% -7.992% / 17.682% -13.0215%,
74
+ curve by 19.5845% 5.185% with 7.1265% 0% / 13.8135% 1.887%,
75
+ curve by 9.8595% 7.9775% with 3.7065% 2.1185% / 7.035% 4.8195%,
76
+ curve by 9.9715% 26.072% with 6.2015% 6.933% / 9.4345% 16.082%,
77
+ curve by 0% 19.461% with 0.074% 1.384% / 0.0745% 17.7715%,
78
+ curve by -13.0065% 29.1155% with -0.511% 11.5345% / -5.021% 21.933%,
79
+ curve by -26.409% 10.119% with -6.991% 6.288% / -16.254% 10.119%,
80
+ curve by -20.945% -5.9995% with -7.6935% 0% / -14.8755% -2.199%,
81
+ curve by -8.713% -7.404% with -3.255% -2.0385% / -6.1905% -4.537%,
82
+ curve by -9.7575% -25.831% with -6.074% -6.9035% / -9.1205% -15.963%,
83
+ close
84
+ );
85
+ --circle: shape(
86
+ evenodd from 13.482% 79.505%,
87
+ curve by -7.1945% -12.47% with -1.4985% -1.8575% / -6.328% -10.225%,
88
+ curve by 0.0985% -33.8965% with -4.1645% -10.7945% / -4.1685% -23.0235%,
89
+ curve by 6.9955% -12.101% with 1.72% -4.3825% / 4.0845% -8.458%,
90
+ curve by 30.125% -17.119% with 7.339% -9.1825% / 18.4775% -15.5135%,
91
+ curve by 13.4165% 0.095% with 4.432% -0.6105% / 8.9505% -0.5855%,
92
+ curve by 29.364% 16.9% with 11.6215% 1.77% / 22.102% 7.9015%,
93
+ curve by 7.176% 12.4145% with 3.002% 3.7195% / 5.453% 7.968%,
94
+ curve by -0.0475% 33.8925% with 4.168% 10.756% / 4.2305% 22.942%,
95
+ curve by -7.1135% 12.2825% with -1.74% 4.4535% / -4.1455% 8.592%,
96
+ curve by -29.404% 16.9075% with -7.202% 8.954% / -18.019% 15.137%,
97
+ curve by -14.19% -0.018% with -4.6635% 0.7255% / -9.4575% 0.7205%,
98
+ curve by -29.226% -16.8875% with -11.573% -1.8065% / -21.9955% -7.9235%,
99
+ close
100
+ );
101
+ }
102
+ }
6
103
 
7
104
  /* ─── Design Tokens ─────────────────────────────────────────── */
8
105
  :root {
@@ -119,25 +119,38 @@ export function ChatWidget({ position = 'bottom-right', onAddToCart }: ChatWidge
119
119
  {/* ── Floating Action Button ── */}
120
120
  <button
121
121
  onClick={isOpen ? () => setIsOpen(false) : handleOpen}
122
- className={`fixed z-[9999] w-14 h-14 shadow-2xl flex items-center justify-center transition-all duration-300 cursor-pointer hover:scale-110 active:scale-95 ${
123
- ui.borderRadius === 'full' ? 'rounded-full' : 'rounded-2xl'
124
- } ${positionClass}`}
125
- style={{ background: `linear-gradient(135deg, ${ui.primaryColor}, ${ui.accentColor})` }}
122
+ className={`group fixed z-[9999] w-14 h-14 drop-shadow-2xl flex items-center justify-center transition-all duration-300 cursor-pointer hover:scale-110 active:scale-95 ${positionClass}`}
126
123
  aria-label="Open chat"
127
124
  >
125
+ {/* Animated Background */}
126
+ <div
127
+ className={`absolute inset-0 transition-all duration-300 overflow-hidden ${
128
+ ui.borderRadius === 'full' ? 'rounded-full' : 'rounded-2xl'
129
+ } group-hover:rounded-none group-hover:animate-shapeshift`}
130
+ >
131
+ <div
132
+ className="absolute top-0 left-0 w-[400%] h-[400%] transition-transform duration-300 group-hover:animate-gradientMove"
133
+ style={{
134
+ background: `linear-gradient(135deg, ${ui.primaryColor}, ${ui.accentColor}, ${ui.primaryColor}, ${ui.accentColor})`
135
+ }}
136
+ />
137
+ </div>
138
+
128
139
  {/* Ripple ring */}
129
140
  <span
130
- className="absolute inset-0 rounded-full animate-ping opacity-20"
141
+ className="absolute inset-0 rounded-full animate-ping opacity-20 pointer-events-none"
131
142
  style={{ background: ui.primaryColor }}
132
143
  />
133
144
 
145
+ {/* Icon Layer */}
134
146
  <span
135
- className={`transition-transform duration-300 ${isOpen ? 'rotate-90 scale-90' : 'rotate-0 scale-100'}`}
147
+ className={`relative z-10 transition-transform duration-300 ${isOpen ? 'rotate-90 scale-90' : 'rotate-0 scale-100'}`}
136
148
  >
137
- {isOpen
138
- ? <X className="w-6 h-6 text-white" />
139
- : <MessageSquare className="w-6 h-6 text-white" />
140
- }
149
+ {isOpen ? (
150
+ <X className="w-6 h-6 text-white" />
151
+ ) : (
152
+ <MessageSquare className="w-6 h-6 text-white" />
153
+ )}
141
154
  </span>
142
155
 
143
156
  {/* Unread badge */}
@@ -44,6 +44,8 @@ export interface VectorDBConfig {
44
44
  * - searchFields?: string[] | string // optional override for which columns receive exact-match boosts
45
45
  */
46
46
  options: Record<string, unknown>;
47
+ /** Optional distance metric (defaults to cosine if not specified and supported by provider) */
48
+ distanceMetric?: 'cosine' | 'euclidean' | 'dotproduct';
47
49
  }
48
50
 
49
51
  // ---------------------------------------------------------------------------
@@ -41,9 +41,13 @@ export class ChromaDBProvider extends BaseVectorProvider {
41
41
  } catch (err) {
42
42
  if (axios.isAxiosError(err) && err.response?.status === 404) {
43
43
  // Create the collection if it doesn't exist
44
+ const metric = this.config.distanceMetric || 'cosine';
45
+ const space = metric === 'euclidean' ? 'l2' : metric === 'dotproduct' ? 'ip' : 'cosine';
46
+
44
47
  console.log(`[ChromaDBProvider] ⏳ Collection "${this.indexName}" not found. Creating...`);
45
48
  const { data } = await this.http.post('/api/v1/collections', {
46
49
  name: this.indexName,
50
+ metadata: { "hnsw:space": space }
47
51
  });
48
52
  this.collectionId = data.id;
49
53
  console.log(`[ChromaDBProvider] ✅ Created collection "${this.indexName}" (id: ${this.collectionId})`);
@@ -66,10 +66,13 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
66
66
  embedding VECTOR(${this.dimensions})
67
67
  )
68
68
  `);
69
+ const metric = this.config.distanceMetric || 'cosine';
70
+ const indexOps = metric === 'euclidean' ? 'vector_l2_ops' : metric === 'dotproduct' ? 'vector_ip_ops' : 'vector_cosine_ops';
71
+
69
72
  await client.query(`
70
73
  CREATE INDEX IF NOT EXISTS ${this.uploadTable}_embedding_idx
71
74
  ON ${this.uploadTable}
72
- USING hnsw (embedding vector_cosine_ops)
75
+ USING hnsw (embedding ${indexOps})
73
76
  `);
74
77
 
75
78
  const res = await client.query(`
@@ -147,10 +150,13 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
147
150
  await client.query(createTableSql);
148
151
 
149
152
  // Create Index
153
+ const metric = this.config.distanceMetric || 'cosine';
154
+ const indexOps = metric === 'euclidean' ? 'vector_l2_ops' : metric === 'dotproduct' ? 'vector_ip_ops' : 'vector_cosine_ops';
155
+
150
156
  await client.query(`
151
157
  CREATE INDEX IF NOT EXISTS "${tableName}_embedding_idx"
152
158
  ON "${tableName}"
153
- USING hnsw (embedding vector_cosine_ops)
159
+ USING hnsw (embedding ${indexOps})
154
160
  `);
155
161
 
156
162
  // Ensure this new table is added to our search scope
@@ -274,6 +280,13 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
274
280
  let sqlQuery = '';
275
281
  let params: unknown[] = [];
276
282
 
283
+ const metric = this.config.distanceMetric || 'cosine';
284
+ const scoreExpr = metric === 'euclidean'
285
+ ? `1 / (1 + (embedding <-> $1::vector))`
286
+ : metric === 'dotproduct'
287
+ ? `(embedding <#> $1::vector) * -1`
288
+ : `1 - (embedding <=> $1::vector)`;
289
+
277
290
  if (queryText) {
278
291
  const hasEntityHints = entityHints.length > 0;
279
292
  const exactNameScoreExpr = hasEntityHints
@@ -290,9 +303,9 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
290
303
  // Actual Hybrid Search: Semantic Vector Score + Full-Text Keyword Rank
291
304
  sqlQuery = `
292
305
  SELECT *,
293
- (1 - (embedding <=> $1::vector)) AS vector_score,
306
+ (${scoreExpr}) AS vector_score,
294
307
  COALESCE(ts_rank(to_tsvector('english', (to_jsonb(t) - 'embedding')::text), to_tsquery('english', $2)), 0) AS keyword_score,
295
- ((1 - (embedding <=> $1::vector)) + (COALESCE(ts_rank(to_tsvector('english', (to_jsonb(t) - 'embedding')::text), to_tsquery('english', $2)), 0) * 2.0) ${exactNameScoreExpr}) AS hybrid_score
308
+ ((${scoreExpr}) + (COALESCE(ts_rank(to_tsvector('english', (to_jsonb(t) - 'embedding')::text), to_tsquery('english', $2)), 0) * 2.0) ${exactNameScoreExpr}) AS hybrid_score
296
309
  FROM "${table}" t
297
310
  ORDER BY hybrid_score DESC
298
311
  LIMIT 50
@@ -302,7 +315,7 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
302
315
  // Fallback to Vector-only Search
303
316
  sqlQuery = `
304
317
  SELECT *,
305
- (1 - (embedding <=> $1::vector)) AS hybrid_score
318
+ (${scoreExpr}) AS hybrid_score
306
319
  FROM "${table}" t
307
320
  ORDER BY hybrid_score DESC
308
321
  LIMIT 50
@@ -97,10 +97,13 @@ export class PostgreSQLProvider extends BaseVectorProvider {
97
97
  embedding VECTOR(${this.dimensions})
98
98
  )
99
99
  `);
100
+ const metric = this.config.distanceMetric || 'cosine';
101
+ const indexOps = metric === 'euclidean' ? 'vector_l2_ops' : metric === 'dotproduct' ? 'vector_ip_ops' : 'vector_cosine_ops';
102
+
100
103
  await client.query(`
101
104
  CREATE INDEX IF NOT EXISTS ${this.tableName}_embedding_idx
102
105
  ON ${this.tableName}
103
- USING hnsw (embedding vector_cosine_ops)
106
+ USING hnsw (embedding ${indexOps})
104
107
  `);
105
108
  } finally {
106
109
  client.release();
@@ -182,11 +185,19 @@ export class PostgreSQLProvider extends BaseVectorProvider {
182
185
  const efSearch = (this.config.options.efSearch as number) || Math.max(topK * 10, 40);
183
186
  await client.query(`SET LOCAL hnsw.ef_search = ${efSearch}`);
184
187
 
188
+ const metric = this.config.distanceMetric || 'cosine';
189
+ const queryOp = metric === 'euclidean' ? '<->' : metric === 'dotproduct' ? '<#>' : '<=>';
190
+ const scoreExpr = metric === 'euclidean'
191
+ ? `1 / (1 + (embedding <-> $1::vector))`
192
+ : metric === 'dotproduct'
193
+ ? `(embedding <#> $1::vector) * -1`
194
+ : `1 - (embedding <=> $1::vector)`;
195
+
185
196
  const result = await client.query(
186
- `SELECT id, content, metadata, 1 - (embedding <=> $1::vector) AS score
197
+ `SELECT id, content, metadata, ${scoreExpr} AS score
187
198
  FROM ${this.tableName}
188
199
  ${whereClause}
189
- ORDER BY embedding <=> $1::vector
200
+ ORDER BY embedding ${queryOp} $1::vector
190
201
  LIMIT $2`,
191
202
  params
192
203
  );
@@ -120,11 +120,14 @@ export class QdrantProvider extends BaseVectorProvider {
120
120
  const opts = this.config.options as Record<string, unknown>;
121
121
  const dimensionsForCreate = (opts.dimensions as number) || 1536;
122
122
 
123
+ const metric = this.config.distanceMetric || 'cosine';
124
+ const distance = metric === 'euclidean' ? 'Euclid' : metric === 'dotproduct' ? 'Dot' : 'Cosine';
125
+
123
126
  console.log(`[QdrantProvider] ⏳ Creating collection "${this.indexName}" (dimensions: ${dimensionsForCreate})...`);
124
127
  await this.http.put(`/collections/${this.indexName}`, {
125
128
  vectors: {
126
129
  size: dimensionsForCreate,
127
- distance: 'Cosine',
130
+ distance: distance,
128
131
  },
129
132
  });
130
133
  console.log(`[QdrantProvider] ✅ Created collection "${this.indexName}"`);
@@ -29,7 +29,8 @@ export class UITransformer {
29
29
  ? retrievedData.filter(item => this.determineStockStatus(item))
30
30
  : retrievedData;
31
31
 
32
- const categories = this.detectCategories(filteredData);
32
+ const groupByField = this.determineGroupByField(userQuery, filteredData);
33
+ const categories = this.detectCategories(filteredData, groupByField);
33
34
  const hasProducts = filteredData.some(item => this.isProductData(item));
34
35
  const isTimeSeries = filteredData.some(item => this.isTimeSeriesData(item));
35
36
  const isTrendQuery = this.isTrendQuery(userQuery);
@@ -39,14 +40,16 @@ export class UITransformer {
39
40
  return this.transformToLineChart(filteredData);
40
41
  }
41
42
 
43
+ const explicitChartRequest = this.isExplicitChartQuery(userQuery);
44
+
42
45
  // 2. High Priority: Products (Always prefer carousel for products unless it's a specific breakdown request)
43
- if (hasProducts && !this.shouldShowCategoryChart(userQuery, categories)) {
46
+ if (hasProducts && !explicitChartRequest && !this.shouldShowCategoryChart(userQuery)) {
44
47
  return this.transformToProductCarousel(filteredData, config, trainedSchema);
45
48
  }
46
49
 
47
50
  // 3. Category breakdowns
48
- if (categories.length > 1 && this.shouldShowCategoryChart(userQuery, categories)) {
49
- return this.transformToPieChart(filteredData);
51
+ if (explicitChartRequest || (categories.length > 1 && this.shouldShowCategoryChart(userQuery))) {
52
+ return this.transformToPieChart(filteredData, groupByField);
50
53
  }
51
54
 
52
55
  // 4. Secondary: Products as carousel if not already handled
@@ -87,13 +90,16 @@ export class UITransformer {
87
90
  * Transform data to pie chart format
88
91
  */
89
92
  private static transformToPieChart(
90
- data: VectorMatch[]
93
+ data: VectorMatch[],
94
+ groupByField: string
91
95
  ): UITransformationResponse {
92
- const categories = this.detectCategories(data);
93
- const categoryData = this.aggregateByCategory(data, categories);
96
+ let categories = this.detectCategories(data, groupByField);
97
+ if (categories.length === 0) categories = ['All Data'];
98
+
99
+ const categoryData = this.aggregateByCategory(data, categories, groupByField);
94
100
 
95
101
  const pieData: PieChartData[] = Object.entries(categoryData).map(([label, count]) => {
96
- const { inStockCount, outOfStockCount } = this.calculateStockCounts(label, data);
102
+ const { inStockCount, outOfStockCount } = this.calculateStockCounts(label, data, groupByField);
97
103
  return {
98
104
  label,
99
105
  value: count as number,
@@ -104,7 +110,7 @@ export class UITransformer {
104
110
 
105
111
  return {
106
112
  type: 'pie_chart',
107
- title: 'Distribution by Category',
113
+ title: `Distribution by ${groupByField.charAt(0).toUpperCase() + groupByField.slice(1)}`,
108
114
  description: `Showing breakdown across ${categories.length} categories`,
109
115
  data: pieData,
110
116
  };
@@ -371,11 +377,16 @@ export class UITransformer {
371
377
  return hasTimeKeyword || hasValidDateValue;
372
378
  }
373
379
 
374
- private static shouldShowCategoryChart(query: string, categories: string[]): boolean {
375
- if (categories.length < 2) {
376
- return false;
377
- }
380
+ private static isExplicitChartQuery(query: string): boolean {
381
+ const normalized = query.toLowerCase();
382
+ return normalized.includes('pie chart') ||
383
+ normalized.includes('piechart') ||
384
+ normalized.includes('bar chart') ||
385
+ normalized.includes('barchart') ||
386
+ normalized.includes('radar chart');
387
+ }
378
388
 
389
+ private static shouldShowCategoryChart(query: string): boolean {
379
390
  const normalized = query.toLowerCase();
380
391
  const chartKeywords = [
381
392
  'distribution',
@@ -389,6 +400,10 @@ export class UITransformer {
389
400
  'split',
390
401
  'category breakdown',
391
402
  'category distribution',
403
+ 'pie chart',
404
+ 'piechart',
405
+ 'bar chart',
406
+ 'barchart'
392
407
  ];
393
408
 
394
409
  return chartKeywords.some(keyword => normalized.includes(keyword));
@@ -501,38 +516,57 @@ export class UITransformer {
501
516
  }
502
517
 
503
518
  /**
504
- * Helper: Detect categories in data
519
+ * Helper: Dynamically determine what field to group by based on user query
520
+ */
521
+ private static determineGroupByField(query: string, data: VectorMatch[]): string {
522
+ const normalized = query.toLowerCase();
523
+ const match = normalized.match(/(?:by|across|per|based on)\s+([a-zA-Z]+)/i);
524
+
525
+ let targetField = 'category';
526
+ if (match && match[1]) {
527
+ let field = match[1].toLowerCase();
528
+ // Un-pluralize common words to match schema keys
529
+ if (field.endsWith('ies')) field = field.replace(/ies$/, 'y');
530
+ else if (field.endsWith('s') && field !== 'status') field = field.slice(0, -1);
531
+
532
+ const stopWords = ['the', 'all', 'different', 'various', 'some', 'these', 'those'];
533
+ if (!stopWords.includes(field)) {
534
+ targetField = field;
535
+ }
536
+ }
537
+
538
+ // Verify field exists, or fallback
539
+ const hasTargetField = data.some(d => d.metadata && d.metadata[targetField] !== undefined);
540
+ if (!hasTargetField && targetField === 'category') {
541
+ if (data.some(d => d.metadata && d.metadata['type'] !== undefined)) return 'type';
542
+ if (data.some(d => d.metadata && d.metadata['brand'] !== undefined)) return 'brand';
543
+ }
544
+
545
+ return targetField;
546
+ }
547
+
548
+ /**
549
+ * Helper: Detect grouping values dynamically
505
550
  */
506
- private static detectCategories(data: VectorMatch[]): string[] {
551
+ private static detectCategories(data: VectorMatch[], groupByField: string): string[] {
507
552
  const categories = new Set<string>();
508
553
 
509
554
  data.forEach(item => {
510
555
  const meta = item.metadata || {};
511
556
 
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
- }
523
-
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());
557
+ if (meta[groupByField] !== undefined) {
558
+ const val = meta[groupByField];
559
+ if (Array.isArray(val)) val.forEach(v => categories.add(String(v)));
560
+ else categories.add(String(val));
561
+ } else if (groupByField === 'category') {
562
+ // Fallbacks for legacy 'category' extraction
563
+ if (meta.type) categories.add(String(meta.type));
564
+ if (meta.tag) {
565
+ const tags = Array.isArray(meta.tag) ? meta.tag : [meta.tag];
566
+ tags.forEach(t => categories.add(String(t)));
567
+ }
568
+ const categoryMatch = item.content.match(/(?:Category|Type|Class):\s*([^\n]+)/i);
569
+ if (categoryMatch) categories.add(categoryMatch[1].trim());
536
570
  }
537
571
  });
538
572
 
@@ -540,11 +574,12 @@ export class UITransformer {
540
574
  }
541
575
 
542
576
  /**
543
- * Helper: Aggregate data by category
577
+ * Helper: Aggregate data dynamically
544
578
  */
545
579
  private static aggregateByCategory(
546
580
  data: VectorMatch[],
547
- categories: string[]
581
+ categories: string[],
582
+ groupByField: string
548
583
  ): Record<string, number> {
549
584
  const result: Record<string, number> = {};
550
585
 
@@ -554,7 +589,13 @@ export class UITransformer {
554
589
 
555
590
  data.forEach(item => {
556
591
  const meta = item.metadata || {};
557
- const itemCategory = (meta.category || meta.type || 'Other') as string;
592
+ let itemCategory = 'Other';
593
+
594
+ if (meta[groupByField] !== undefined) {
595
+ itemCategory = String(meta[groupByField]);
596
+ } else if (groupByField === 'category' && meta.type) {
597
+ itemCategory = String(meta.type);
598
+ }
558
599
 
559
600
  if (Object.prototype.hasOwnProperty.call(result, itemCategory)) {
560
601
  result[itemCategory]++;
@@ -615,15 +656,21 @@ export class UITransformer {
615
656
  }
616
657
 
617
658
  /**
618
- * Helper: Calculate stock counts by category
659
+ * Helper: Calculate stock counts dynamically
619
660
  */
620
- private static calculateStockCounts(category: string, data: VectorMatch[]): { inStockCount: number; outOfStockCount: number } {
661
+ private static calculateStockCounts(category: string, data: VectorMatch[], groupByField: string): { inStockCount: number; outOfStockCount: number } {
621
662
  let inStock = 0;
622
663
  let outOfStock = 0;
623
664
 
624
665
  data.forEach(d => {
625
666
  const meta = d.metadata || {};
626
- const itemCategory = (meta.category || meta.type || 'Other') as string;
667
+ let itemCategory = 'Other';
668
+ if (meta[groupByField] !== undefined) {
669
+ itemCategory = String(meta[groupByField]);
670
+ } else if (groupByField === 'category' && meta.type) {
671
+ itemCategory = String(meta.type);
672
+ }
673
+
627
674
  if (itemCategory === category) {
628
675
  if (this.determineStockStatus(d)) {
629
676
  inStock++;
@@ -772,12 +819,13 @@ DATA SHAPE per type:
772
819
  - text: { "content": "<prose answer>" }
773
820
 
774
821
  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
822
+ 1. IF the user explicitly asks for a "pie chart", "bar chart", or "table", you MUST output that exact type. Do not deviate from their request.
823
+ 2. bar_chart comparing quantities across categories (e.g. sales by region, price by product). Use this when there is only ONE value per category.
824
+ 3. line_chart trends or changes over time (dates, months, years, sequential events)
825
+ 4. pie_chart proportional breakdown or percentage distribution
826
+ 5. radar_chart comparing 2 or more products across MULTIPLE attributes.
827
+ 6. table multi-field structured records where each item has ≥ 3 attributes
828
+ 7. text → conversational or free-form answers where no chart adds value
781
829
 
782
830
  IMPORTANT:
783
831
  - Aggregate numeric values from the raw data — do not pass raw object arrays as data.