@retrivora-ai/rag-engine 1.9.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/{ILLMProvider-BOJFz3Na.d.mts → ILLMProvider-BQyKZLnd.d.mts} +10 -0
  2. package/dist/{ILLMProvider-BOJFz3Na.d.ts → ILLMProvider-BQyKZLnd.d.ts} +10 -0
  3. package/dist/handlers/index.d.mts +2 -2
  4. package/dist/handlers/index.d.ts +2 -2
  5. package/dist/handlers/index.js +1719 -467
  6. package/dist/handlers/index.mjs +1718 -466
  7. package/dist/{index-BwpcaziY.d.ts → index-A0GqPsaG.d.ts} +1 -1
  8. package/dist/{index-D3V9Et2M.d.mts → index-CDftK3qs.d.mts} +1 -1
  9. package/dist/index.css +26 -0
  10. package/dist/index.d.mts +2 -2
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.js +246 -76
  13. package/dist/index.mjs +248 -76
  14. package/dist/server.d.mts +32 -5
  15. package/dist/server.d.ts +32 -5
  16. package/dist/server.js +1726 -671
  17. package/dist/server.mjs +1724 -669
  18. package/package.json +1 -1
  19. package/src/components/MarkdownComponents.tsx +3 -3
  20. package/src/components/MessageBubble.tsx +49 -2
  21. package/src/components/ProductCard.tsx +33 -6
  22. package/src/components/UIDispatcher.tsx +1 -0
  23. package/src/components/VisualizationRenderer.tsx +143 -11
  24. package/src/config/EmbeddingStrategy.ts +5 -4
  25. package/src/config/RagConfig.ts +10 -0
  26. package/src/config/serverConfig.ts +16 -1
  27. package/src/core/LLMRouter.ts +79 -0
  28. package/src/core/Pipeline.ts +262 -45
  29. package/src/core/ProviderRegistry.ts +6 -0
  30. package/src/core/QueryProcessor.ts +98 -0
  31. package/src/handlers/index.ts +8 -5
  32. package/src/hooks/useRagChat.ts +53 -17
  33. package/src/llm/providers/UniversalLLMAdapter.ts +110 -13
  34. package/src/providers/vectordb/ChromaDBProvider.ts +13 -2
  35. package/src/providers/vectordb/MilvusProvider.ts +18 -2
  36. package/src/providers/vectordb/MultiTablePostgresProvider.ts +12 -12
  37. package/src/providers/vectordb/PostgreSQLProvider.ts +1 -1
  38. package/src/providers/vectordb/QdrantProvider.ts +1 -1
  39. package/src/providers/vectordb/RedisProvider.ts +3 -4
  40. package/src/providers/vectordb/WeaviateProvider.ts +41 -3
  41. package/src/types/index.ts +26 -0
  42. package/src/utils/ProductExtractor.ts +5 -3
  43. package/src/utils/UITransformer.ts +1348 -489
  44. package/src/utils/synonyms.ts +2 -0
@@ -128,10 +128,36 @@ export function useRagChat(
128
128
  let sources: VectorMatch[] = [];
129
129
  let uiTransformation: unknown = null;
130
130
  let trace: ObservabilityTrace | undefined;
131
- // Buffer for partial SSE frames that arrive split across reads
132
131
  let buffer = '';
133
132
 
134
- // Add a placeholder assistant message that we will update incrementally
133
+ // ── RAF-based render batching ─────────────────────────────────────
134
+ // Accumulate text chunks in a ref and flush to React state at ~60fps
135
+ // instead of calling setMessages on every SSE frame. This eliminates
136
+ // O(N) ReactMarkdown re-parses where N = number of token chunks.
137
+ let pendingFlush = false;
138
+ const pendingContentRef = { current: '' };
139
+
140
+ const flushTextUpdate = (msgId: string) => {
141
+ pendingFlush = false;
142
+ const snapshot = pendingContentRef.current;
143
+ setMessages((prev) =>
144
+ prev.map((msg) =>
145
+ msg.id === msgId
146
+ ? { ...msg, content: snapshot }
147
+ : msg
148
+ )
149
+ );
150
+ };
151
+
152
+ const scheduleFlush = (msgId: string) => {
153
+ if (!pendingFlush) {
154
+ pendingFlush = true;
155
+ requestAnimationFrame(() => flushTextUpdate(msgId));
156
+ }
157
+ };
158
+ // ─────────────────────────────────────────────────────────────────
159
+
160
+ // Add a placeholder assistant message
135
161
  const assistantMessageId = `assistant_${Date.now()}`;
136
162
  setMessages((prev) => [
137
163
  ...prev,
@@ -149,40 +175,50 @@ export function useRagChat(
149
175
 
150
176
  buffer += decoder.decode(value, { stream: true });
151
177
 
152
- // Only process complete frames (ending with \n\n)
153
178
  const lastBoundary = buffer.lastIndexOf('\n\n');
154
- if (lastBoundary === -1) continue; // Wait for more data
179
+ if (lastBoundary === -1) continue;
155
180
 
156
181
  const complete = buffer.slice(0, lastBoundary + 2);
157
182
  buffer = buffer.slice(lastBoundary + 2);
158
183
 
184
+ let hasNonTextFrame = false;
185
+
159
186
  for (const frame of parseSseChunk(complete)) {
160
187
  if (frame.type === 'text' && frame.text) {
161
188
  assistantContent += frame.text;
189
+ pendingContentRef.current = assistantContent;
190
+ // Schedule a batched RAF flush instead of immediate setMessages
191
+ scheduleFlush(assistantMessageId);
162
192
  } else if (frame.type === 'metadata') {
163
193
  sources = frame.sources ?? [];
194
+ hasNonTextFrame = true;
164
195
  } else if (frame.type === 'ui_transformation') {
165
196
  uiTransformation = frame.data;
197
+ hasNonTextFrame = true;
166
198
  } else if (frame.type === 'observability') {
167
199
  trace = (frame as SseObservabilityFrame).data;
200
+ hasNonTextFrame = true;
168
201
  } else if (frame.type === 'error') {
169
202
  setError(frame.error || 'Stream error');
170
203
  }
171
204
  }
172
205
 
173
- // Update the assistant message with accumulated content
174
- setMessages((prev) =>
175
- prev.map((msg) =>
176
- msg.id === assistantMessageId
177
- ? {
178
- ...msg,
179
- content: assistantContent,
180
- sources: sources.length > 0 ? sources : msg.sources,
181
- uiTransformation: uiTransformation || (msg as RagMessage).uiTransformation
182
- }
183
- : msg
184
- )
185
- );
206
+ // Flush non-text metadata immediately (sources, UI transform, trace)
207
+ if (hasNonTextFrame) {
208
+ pendingFlush = false; // cancel pending text flush — we'll do a full update
209
+ setMessages((prev) =>
210
+ prev.map((msg) =>
211
+ msg.id === assistantMessageId
212
+ ? {
213
+ ...msg,
214
+ content: assistantContent,
215
+ sources: sources.length > 0 ? sources : msg.sources,
216
+ uiTransformation: uiTransformation || (msg as RagMessage).uiTransformation,
217
+ }
218
+ : msg
219
+ )
220
+ );
221
+ }
186
222
  }
187
223
 
188
224
  // Process any remaining buffered data
@@ -25,6 +25,9 @@ export class UniversalLLMAdapter implements ILLMProvider {
25
25
  private readonly systemPrompt: string;
26
26
  private readonly maxTokens: number;
27
27
  private readonly temperature: number;
28
+ private readonly baseUrl: string;
29
+ private readonly apiKey?: string;
30
+ private readonly resolvedHeaders: Record<string, string>;
28
31
 
29
32
  constructor(config: LLMConfig | EmbeddingConfig) {
30
33
  this.model = config.model;
@@ -46,19 +49,22 @@ export class UniversalLLMAdapter implements ILLMProvider {
46
49
  this.systemPrompt = llmConfig.systemPrompt ?? 'You are a helpful AI assistant. Use the provided context to answer the user.';
47
50
  this.maxTokens = llmConfig.maxTokens ?? 1024;
48
51
  this.temperature = llmConfig.temperature ?? 0;
52
+ this.apiKey = config.apiKey;
49
53
 
50
- const baseUrl = llmConfig.baseUrl ?? this.opts.baseUrl;
51
- if (!baseUrl) {
54
+ this.baseUrl = llmConfig.baseUrl ?? this.opts.baseUrl ?? '';
55
+ if (!this.baseUrl) {
52
56
  throw new Error('[UniversalLLMAdapter] baseUrl is required in config or config.options');
53
57
  }
54
58
 
59
+ this.resolvedHeaders = {
60
+ 'Content-Type': 'application/json',
61
+ ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
62
+ ...(this.opts.headers || {}),
63
+ };
64
+
55
65
  this.http = axios.create({
56
- baseURL: baseUrl,
57
- headers: {
58
- 'Content-Type': 'application/json',
59
- ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
60
- ...(this.opts.headers || {}),
61
- },
66
+ baseURL: this.baseUrl,
67
+ headers: this.resolvedHeaders,
62
68
  timeout: this.opts.timeout ?? 60_000,
63
69
  });
64
70
  }
@@ -73,7 +79,6 @@ export class UniversalLLMAdapter implements ILLMProvider {
73
79
 
74
80
  let payload: unknown;
75
81
  if (this.opts.chatPayloadTemplate) {
76
- // The user wants to map messages and parameters to their unique JSON shape
77
82
  payload = buildPayload(this.opts.chatPayloadTemplate, {
78
83
  model: this.model,
79
84
  messages: formattedMessages,
@@ -81,7 +86,6 @@ export class UniversalLLMAdapter implements ILLMProvider {
81
86
  temperature: this.temperature,
82
87
  });
83
88
  } else {
84
- // Fallback to OpenAI standard which covers 90% of open-source models (vLLM, LMStudio, Ollama, etc.)
85
89
  payload = {
86
90
  model: this.model,
87
91
  messages: formattedMessages,
@@ -102,6 +106,102 @@ export class UniversalLLMAdapter implements ILLMProvider {
102
106
  return String(result);
103
107
  }
104
108
 
109
+ /**
110
+ * Streaming chat using native fetch + ReadableStream.
111
+ * Parses OpenAI-compatible SSE frames: `data: {...}\n\n`
112
+ * Works with vLLM, LMStudio, Together AI, Fireworks, and any OpenAI-compatible API.
113
+ */
114
+ async *chatStream(messages: ChatMessage[], context?: string): AsyncIterable<string> {
115
+ const path = this.opts.chatPath ?? '/chat/completions';
116
+ const url = `${this.baseUrl.replace(/\/$/, '')}${path}`;
117
+ // Delta extract path — same root as chat but with delta instead of message
118
+ const extractPath = (this.opts.responseExtractPath ?? 'choices[0].message.content')
119
+ .replace('message.content', 'delta.content');
120
+
121
+ const formattedMessages = [
122
+ { role: 'system', content: `${this.systemPrompt}\n\nContext:\n${context ?? 'None'}` },
123
+ ...messages,
124
+ ];
125
+
126
+ let payload: unknown;
127
+ if (this.opts.chatPayloadTemplate) {
128
+ payload = buildPayload(this.opts.chatPayloadTemplate, {
129
+ model: this.model,
130
+ messages: formattedMessages,
131
+ maxTokens: this.maxTokens,
132
+ temperature: this.temperature,
133
+ });
134
+ // Inject stream: true into the payload if it's an object
135
+ if (typeof payload === 'object' && payload !== null) {
136
+ (payload as Record<string, unknown>).stream = true;
137
+ }
138
+ } else {
139
+ payload = {
140
+ model: this.model,
141
+ messages: formattedMessages,
142
+ max_tokens: this.maxTokens,
143
+ temperature: this.temperature,
144
+ stream: true,
145
+ };
146
+ }
147
+
148
+ const response = await fetch(url, {
149
+ method: 'POST',
150
+ headers: this.resolvedHeaders,
151
+ body: JSON.stringify(payload),
152
+ });
153
+
154
+ if (!response.ok) {
155
+ const errorText = await response.text().catch(() => response.statusText);
156
+ throw new Error(`[UniversalLLMAdapter] Streaming request failed (${response.status}): ${errorText}`);
157
+ }
158
+
159
+ if (!response.body) {
160
+ throw new Error('[UniversalLLMAdapter] Response body is null — server did not send a streaming response.');
161
+ }
162
+
163
+ const reader = response.body.getReader();
164
+ const decoder = new TextDecoder('utf-8');
165
+ let buffer = '';
166
+
167
+ try {
168
+ while (true) {
169
+ const { done, value } = await reader.read();
170
+ if (done) break;
171
+
172
+ buffer += decoder.decode(value, { stream: true });
173
+ const lines = buffer.split('\n');
174
+ buffer = lines.pop() ?? '';
175
+
176
+ for (const line of lines) {
177
+ const trimmed = line.trim();
178
+ if (!trimmed || trimmed === 'data: [DONE]') continue;
179
+ if (!trimmed.startsWith('data:')) continue;
180
+
181
+ try {
182
+ const json = JSON.parse(trimmed.slice(5).trim());
183
+ const text = resolvePath(json, extractPath);
184
+ if (text && typeof text === 'string') yield text;
185
+ } catch {
186
+ // Malformed SSE line — skip
187
+ }
188
+ }
189
+ }
190
+
191
+ // Flush remaining buffer
192
+ if (buffer.trim() && buffer.trim() !== 'data: [DONE]') {
193
+ const jsonStr = buffer.replace(/^data:\s*/, '').trim();
194
+ try {
195
+ const json = JSON.parse(jsonStr);
196
+ const text = resolvePath(json, extractPath);
197
+ if (text && typeof text === 'string') yield text;
198
+ } catch { /* ignore final partial line */ }
199
+ }
200
+ } finally {
201
+ reader.releaseLock();
202
+ }
203
+ }
204
+
105
205
  async embed(text: string): Promise<number[]> {
106
206
  const path = this.opts.embedPath ?? '/embeddings';
107
207
 
@@ -112,7 +212,6 @@ export class UniversalLLMAdapter implements ILLMProvider {
112
212
  input: text,
113
213
  });
114
214
  } else {
115
- // OpenAI standard fallback
116
215
  payload = {
117
216
  model: this.model,
118
217
  input: text,
@@ -133,8 +232,6 @@ export class UniversalLLMAdapter implements ILLMProvider {
133
232
 
134
233
  async batchEmbed(texts: string[]): Promise<number[][]> {
135
234
  const vectors: number[][] = [];
136
- // Sequential fallback if the provider doesn't have a specific batch template
137
- // In production, we should add a batchEmbedPayloadTemplate to UniversalLLMOptions
138
235
  for (const text of texts) {
139
236
  vectors.push(await this.embed(text));
140
237
  }
@@ -70,12 +70,23 @@ export class ChromaDBProvider extends BaseVectorProvider {
70
70
  await this.http.post(`/api/v1/collections/${this.collectionId}/add`, payload);
71
71
  }
72
72
 
73
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
74
73
  async query(vector: number[], topK: number, namespace?: string, _filter?: Record<string, unknown>): Promise<VectorMatch[]> {
74
+ const sanitizedFilter = this.sanitizeFilter(_filter);
75
+ const whereClauses: Record<string, unknown>[] = [];
76
+ if (namespace) whereClauses.push({ namespace: { $eq: namespace } });
77
+ Object.entries(sanitizedFilter).forEach(([key, value]) => {
78
+ if (key === 'namespace') return;
79
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
80
+ whereClauses.push({ [key]: { $eq: value } });
81
+ }
82
+ });
83
+
75
84
  const payload = {
76
85
  query_embeddings: [vector],
77
86
  n_results: topK,
78
- where: namespace ? { namespace: { $eq: namespace } } : undefined,
87
+ where: whereClauses.length > 1
88
+ ? { $and: whereClauses }
89
+ : whereClauses[0],
79
90
  };
80
91
  const { data } = await this.http.post(`/api/v1/collections/${this.collectionId}/query`, payload);
81
92
 
@@ -68,13 +68,29 @@ export class MilvusProvider extends BaseVectorProvider {
68
68
  await this.http.post('/v1/vector/upsert', payload);
69
69
  }
70
70
 
71
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
72
71
  async query(vector: number[], topK: number, namespace?: string, _filter?: Record<string, unknown>): Promise<VectorMatch[]> {
72
+ const sanitizedFilter = this.sanitizeFilter(_filter);
73
+
74
+ // Build Milvus boolean expression from filter fields.
75
+ // Namespace filtering is expressed as a field equality check.
76
+ const filterParts: string[] = [];
77
+ if (namespace) {
78
+ filterParts.push(`namespace == "${namespace}"`);
79
+ }
80
+ for (const [key, value] of Object.entries(sanitizedFilter)) {
81
+ if (key === 'queryText' || key === 'keywords') continue;
82
+ if (typeof value === 'string') {
83
+ filterParts.push(`metadata["${key}"] == "${value.replace(/"/g, '\\"')}"`);
84
+ } else if (typeof value === 'number') {
85
+ filterParts.push(`metadata["${key}"] == ${value}`);
86
+ }
87
+ }
88
+
73
89
  const payload = {
74
90
  collectionName: this.indexName,
75
91
  vector: vector,
76
92
  limit: topK,
77
- filter: namespace ? `namespace == "${namespace}"` : undefined,
93
+ filter: filterParts.length > 0 ? filterParts.join(' && ') : undefined,
78
94
  outputFields: ['content', 'metadata'],
79
95
  searchParams: {
80
96
  nprobe: (this.config.options.nprobe as number) || 16,
@@ -235,12 +235,9 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
235
235
  console.log(`[MultiTablePostgresProvider] --- Starting Multi-Table Search ---`);
236
236
 
237
237
  const queryText = _filter?.queryText as string | undefined;
238
- const entityHints = Array.isArray(_filter?.__entityHints)
239
- ? (_filter.__entityHints as unknown[])
240
- .filter((hint): hint is { value: string; field?: string } =>
241
- typeof hint === 'object' && hint !== null && typeof (hint as Record<string, unknown>).value === 'string'
242
- )
243
- .map((hint) => hint.value.trim().toLowerCase())
238
+ const entityHints = Array.isArray(_filter?.keywords)
239
+ ? (_filter.keywords as string[])
240
+ .map((k) => k.trim().toLowerCase())
244
241
  .filter(Boolean)
245
242
  : [];
246
243
 
@@ -252,15 +249,17 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
252
249
  if (entityHints.length > 0) {
253
250
  // Use extracted entities as high-signal keywords
254
251
  return entityHints
255
- .map(h => h.includes(' ') ? `"${h}"` : h) // Wrap multi-word hints in quotes
252
+ .map(h => h.replace(/\s+/g, ' & ')) // Replace spaces with & for valid tsquery
256
253
  .join(' & ');
257
254
  }
258
255
 
259
256
  if (queryText) {
260
257
  // Fallback to cleaned raw text if no high-signal hints were extracted
258
+ // Added 'get', 'details', 'information', 'info' to stop words
261
259
  return queryText
262
260
  .toLowerCase()
263
- .replace(/\b(show|me|the|product[s]?|item[s]?|card[s]?|of|category|find|list|browse|what|are|display|all|under|for|in|with|about)\b/g, '')
261
+ .replace(/\b(show|me|the|product[s]?|item[s]?|card[s]?|organization[s]?|comp(?:any|anies)|of|category|find|list|browse|what|are|display|all|whose|that|which|have|has|having|greater|than|more|less|equal|equals|above|below|over|under|at|least|most|for|in|with|about|get|details|information|info)\b/g, '')
262
+ .replace(/\b\d+(?:\.\d+)?\b/g, '')
264
263
  .trim()
265
264
  .replace(/\s+/g, ' & ');
266
265
  }
@@ -268,13 +267,14 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
268
267
  };
269
268
 
270
269
  const dynamicKeywordQuery = getDynamicKeywordQuery();
270
+ const tableLimit = Math.max(topK, 50);
271
271
 
272
272
  const queryPromises = this.tables.map(async (table) => {
273
273
  try {
274
274
  let sqlQuery = '';
275
275
  let params: unknown[] = [];
276
276
 
277
- if (queryText) {
277
+ if (queryText && dynamicKeywordQuery) {
278
278
  const hasEntityHints = entityHints.length > 0;
279
279
  const exactNameScoreExpr = hasEntityHints
280
280
  ? `+ (
@@ -295,17 +295,17 @@ export class MultiTablePostgresProvider extends BaseVectorProvider {
295
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
296
296
  FROM "${table}" t
297
297
  ORDER BY hybrid_score DESC
298
- LIMIT 50
298
+ LIMIT ${tableLimit}
299
299
  `;
300
300
  params = [vectorLiteral, dynamicKeywordQuery];
301
301
  } else {
302
302
  // Fallback to Vector-only Search
303
303
  sqlQuery = `
304
304
  SELECT *,
305
- (1 - (embedding <=> $1::vector)) AS hybrid_score
305
+ (1 - (embedding <=> $1::vector)) AS hybrid_score
306
306
  FROM "${table}" t
307
307
  ORDER BY hybrid_score DESC
308
- LIMIT 50
308
+ LIMIT ${tableLimit}
309
309
  `;
310
310
  params = [vectorLiteral];
311
311
  }
@@ -170,7 +170,7 @@ export class PostgreSQLProvider extends BaseVectorProvider {
170
170
  const filterConditions = Object.entries(publicFilter)
171
171
  .map(([key, val]) => {
172
172
  const paramIdx = params.length + 1;
173
- params.push(JSON.stringify(val));
173
+ params.push(String(val));
174
174
  return `metadata->>'${key}' = $${paramIdx}`;
175
175
  })
176
176
  .join(' AND ');
@@ -211,7 +211,7 @@ export class QdrantProvider extends BaseVectorProvider {
211
211
  limit: topK,
212
212
  with_payload: true,
213
213
  params: {
214
- hnsw_ef: (this.config.options.efSearch as number) || Math.max(topK * 20, 128),
214
+ hnsw_ef: ((this.config.options as Record<string, unknown>)?.efSearch as number | undefined) || Math.max(topK * 20, 128),
215
215
  exact: false
216
216
  },
217
217
  filter: must.length > 0 ? { must } : undefined,
@@ -90,16 +90,15 @@ export class RedisProvider extends BaseVectorProvider {
90
90
  }
91
91
 
92
92
  /**
93
- * Redis is TCP-based and has no HTTP health endpoint.
94
- * Returns true; actual connectivity is validated on the first operation.
93
+ * Check reachability via a PING command.
94
+ * Returns false on connection failure so health checks surface real problems.
95
95
  */
96
96
  async ping(): Promise<boolean> {
97
97
  try {
98
98
  await this.http.post('/', ['PING']);
99
99
  return true;
100
100
  } catch {
101
- // If REST ping fails, assume connectivity will be validated on first use
102
- return true;
101
+ return false;
103
102
  }
104
103
  }
105
104
 
@@ -40,6 +40,7 @@ export class WeaviateProvider extends BaseVectorProvider {
40
40
  }
41
41
 
42
42
  async upsert(doc: UpsertDocument, namespace?: string): Promise<void> {
43
+ const primitiveMetadata = this.extractPrimitiveMetadata(doc.metadata);
43
44
  const payload = {
44
45
  class: this.indexName,
45
46
  id: doc.id,
@@ -47,6 +48,7 @@ export class WeaviateProvider extends BaseVectorProvider {
47
48
  properties: {
48
49
  content: doc.content,
49
50
  metadata: JSON.stringify(doc.metadata || {}),
51
+ ...primitiveMetadata,
50
52
  namespace: namespace || '',
51
53
  },
52
54
  };
@@ -62,6 +64,7 @@ export class WeaviateProvider extends BaseVectorProvider {
62
64
  properties: {
63
65
  content: doc.content,
64
66
  metadata: JSON.stringify(doc.metadata || {}),
67
+ ...this.extractPrimitiveMetadata(doc.metadata),
65
68
  namespace: namespace || '',
66
69
  },
67
70
  })),
@@ -70,12 +73,14 @@ export class WeaviateProvider extends BaseVectorProvider {
70
73
  }
71
74
 
72
75
  async query(vector: number[], topK: number, namespace?: string, _filter?: Record<string, unknown>): Promise<VectorMatch[]> {
76
+ // IMPORTANT: read queryText BEFORE calling sanitizeFilter() because sanitizeFilter strips it.
77
+ const queryText = _filter?.queryText as string | undefined;
73
78
  const sanitizedFilter = this.sanitizeFilter(_filter);
74
- const queryText = sanitizedFilter.queryText as string;
75
79
 
76
- const searchParams = queryText
80
+ const searchParams = queryText
77
81
  ? `hybrid: { query: ${JSON.stringify(queryText)}, alpha: 0.5 }`
78
82
  : `nearVector: { vector: ${JSON.stringify(vector)} }`;
83
+ const where = this.buildWhereFilter(namespace, sanitizedFilter);
79
84
 
80
85
  const graphqlQuery = {
81
86
  query: `
@@ -84,7 +89,7 @@ export class WeaviateProvider extends BaseVectorProvider {
84
89
  ${this.indexName}(
85
90
  ${searchParams}
86
91
  limit: ${topK}
87
- ${namespace ? `where: { path: ["namespace"], operator: Equal, valueString: "${namespace}" }` : ''}
92
+ ${where ? `where: ${where}` : ''}
88
93
  ) {
89
94
  content
90
95
  metadata
@@ -132,4 +137,37 @@ export class WeaviateProvider extends BaseVectorProvider {
132
137
  }
133
138
 
134
139
  async disconnect(): Promise<void> {}
140
+
141
+ private extractPrimitiveMetadata(metadata?: Record<string, unknown>): Record<string, string | number | boolean> {
142
+ const result: Record<string, string | number | boolean> = {};
143
+ Object.entries(metadata || {}).forEach(([key, value]) => {
144
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
145
+ result[key] = value;
146
+ }
147
+ });
148
+ return result;
149
+ }
150
+
151
+ private buildWhereFilter(namespace?: string, filter?: Record<string, unknown>): string | undefined {
152
+ const operands: string[] = [];
153
+ if (namespace) operands.push(this.weaviateOperand('namespace', namespace));
154
+
155
+ Object.entries(filter || {}).forEach(([key, value]) => {
156
+ if (key === 'namespace') return;
157
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
158
+ operands.push(this.weaviateOperand(key, value));
159
+ }
160
+ });
161
+
162
+ if (operands.length === 0) return undefined;
163
+ if (operands.length === 1) return operands[0];
164
+ return `{ operator: And, operands: [${operands.join(', ')}] }`;
165
+ }
166
+
167
+ private weaviateOperand(key: string, value: string | number | boolean): string {
168
+ const path = `path: [${JSON.stringify(key)}], operator: Equal`;
169
+ if (typeof value === 'number') return `{ ${path}, valueNumber: ${value} }`;
170
+ if (typeof value === 'boolean') return `{ ${path}, valueBoolean: ${value} }`;
171
+ return `{ ${path}, valueString: ${JSON.stringify(value)} }`;
172
+ }
135
173
  }
@@ -125,7 +125,12 @@ export type VisualizationType =
125
125
  | 'pie_chart'
126
126
  | 'bar_chart'
127
127
  | 'line_chart'
128
+ | 'histogram'
129
+ | 'horizontal_bar'
130
+ | 'scatter_plot'
128
131
  | 'radar_chart'
132
+ | 'metric_card'
133
+ | 'geo_map'
129
134
  | 'table'
130
135
  | 'product_carousel'
131
136
  | 'carousel'
@@ -173,6 +178,27 @@ export interface LineChartDataPoint {
173
178
  [key: string]: unknown;
174
179
  }
175
180
 
181
+ /**
182
+ * Scatter plot data point
183
+ */
184
+ export interface ScatterPlotDataPoint {
185
+ x: number;
186
+ y: number;
187
+ label?: string;
188
+ [key: string]: unknown;
189
+ }
190
+
191
+ /**
192
+ * KPI / metric card representation
193
+ */
194
+ export interface MetricCardData {
195
+ label: string;
196
+ value: number;
197
+ operation?: 'sum' | 'average' | 'count' | 'min' | 'max' | 'median';
198
+ unit?: string;
199
+ details?: Record<string, string | number | boolean>;
200
+ }
201
+
176
202
  /**
177
203
  * Table representation
178
204
  */
@@ -199,11 +199,13 @@ export function extractProductsFromSources(sources: VectorMatch[] | undefined, i
199
199
  .filter(s => {
200
200
  const m = s.metadata ?? {};
201
201
  const keys = Object.keys(m).map(k => k.toLowerCase());
202
- const hasProductKey = keys.some(k =>
203
- ['price', 'image', 'img', 'thumbnail', 'images', 'brand', 'product', 'sku', 'category', 'model', 'cost'].includes(k)
202
+ const hasStrongProductKey = keys.some(k =>
203
+ ['price', 'image', 'img', 'thumbnail', 'images', 'product', 'sku', 'cost'].includes(k)
204
204
  );
205
+ const hasProductIdentity = keys.some(k => ['brand', 'model'].includes(k)) &&
206
+ keys.some(k => ['name', 'title', 'product_name', 'product'].includes(k));
205
207
  const hasPricePattern = /\$\s*\d+/.test(s.content);
206
- return hasProductKey || hasPricePattern || m.type === 'product';
208
+ return hasStrongProductKey || hasProductIdentity || hasPricePattern || m.type === 'product';
207
209
  })
208
210
  .map(s => {
209
211
  const m = (s.metadata ?? {}) as Record<string, unknown>;