@retrivora-ai/rag-engine 1.7.9 → 1.8.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 (47) hide show
  1. package/dist/DocumentChunker-Dh9TvmGG.d.mts +45 -0
  2. package/dist/DocumentChunker-Dh9TvmGG.d.ts +45 -0
  3. package/dist/{index-wCRqMtdX.d.mts → ILLMProvider-BfRgI1Xh.d.mts} +59 -1
  4. package/dist/{index-wCRqMtdX.d.ts → ILLMProvider-BfRgI1Xh.d.ts} +59 -1
  5. package/dist/MultiTablePostgresProvider-YY7LPNJK.mjs +8 -0
  6. package/dist/{chunk-UHGYLTTY.mjs → chunk-BFYLQYQU.mjs} +846 -458
  7. package/dist/chunk-R3RGUMHE.mjs +218 -0
  8. package/dist/handlers/index.d.mts +2 -2
  9. package/dist/handlers/index.d.ts +2 -2
  10. package/dist/handlers/index.js +1033 -622
  11. package/dist/handlers/index.mjs +1 -1
  12. package/dist/{index-BDqz2_Yu.d.ts → index-1Z4GuYBi.d.ts} +7 -1
  13. package/dist/{index-DhsG2o5q.d.mts → index-BV0z5mb6.d.mts} +7 -1
  14. package/dist/index.d.mts +3 -3
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.js +507 -352
  17. package/dist/index.mjs +427 -253
  18. package/dist/server.d.mts +35 -5
  19. package/dist/server.d.ts +35 -5
  20. package/dist/server.js +1183 -795
  21. package/dist/server.mjs +163 -176
  22. package/package.json +4 -2
  23. package/src/app/page.tsx +1 -2
  24. package/src/components/DynamicChart.tsx +10 -35
  25. package/src/components/MessageBubble.tsx +223 -74
  26. package/src/components/ProductCard.tsx +2 -2
  27. package/src/components/VisualizationRenderer.tsx +347 -247
  28. package/src/config/RagConfig.ts +5 -0
  29. package/src/config/serverConfig.ts +3 -1
  30. package/src/core/Pipeline.ts +70 -209
  31. package/src/core/ProviderRegistry.ts +2 -2
  32. package/src/core/VectorPlugin.ts +9 -0
  33. package/src/handlers/index.ts +23 -7
  34. package/src/hooks/useRagChat.ts +2 -2
  35. package/src/llm/LLMFactory.ts +54 -2
  36. package/src/llm/providers/AnthropicProvider.ts +12 -8
  37. package/src/llm/providers/GeminiProvider.ts +188 -143
  38. package/src/llm/providers/OllamaProvider.ts +7 -3
  39. package/src/llm/providers/OpenAIProvider.ts +12 -8
  40. package/src/types/chat.ts +6 -0
  41. package/src/types/index.ts +81 -0
  42. package/src/utils/SchemaMapper.ts +129 -0
  43. package/src/utils/UITransformer.ts +524 -194
  44. package/dist/DocumentChunker-Bmscbh-X.d.ts +0 -93
  45. package/dist/DocumentChunker-DCuxrOdM.d.mts +0 -93
  46. package/dist/PostgreSQLProvider-BMOETDZA.mjs +0 -8
  47. package/dist/chunk-FLOSGE6A.mjs +0 -202
@@ -182,6 +182,11 @@ export interface RAGConfig {
182
182
  * Used to dynamically extract hints from natural language queries.
183
183
  */
184
184
  filterableFields?: string[];
185
+ /** Optional mapping from database metadata fields to standard UI properties.
186
+ * Useful for databases with custom schemas.
187
+ * e.g., { "name": "ProductName", "price": "SalesPrice", "image": "ThumbnailUrl" }
188
+ */
189
+ uiMapping?: Record<string, string>;
185
190
  }
186
191
 
187
192
  // ---------------------------------------------------------------------------
@@ -76,7 +76,9 @@ export function getEnvConfig(env: Record<string, string | undefined> = process.e
76
76
  vectorDbOptions.apiKey = readString(env, 'PINECONE_API_KEY') ?? (base?.vectorDb?.options?.apiKey as string) ?? '';
77
77
  vectorDbOptions.indexName = readString(env, 'PINECONE_INDEX') ?? (base?.vectorDb?.indexName as string) ?? (base?.vectorDb?.options?.indexName as string);
78
78
  } else if (vectorProvider === 'pgvector' || vectorProvider === 'postgresql') {
79
- vectorDbOptions.connectionString = readString(env, 'PGVECTOR_CONNECTION_STRING') ?? (base?.vectorDb?.options?.connectionString as string) ?? '';
79
+ vectorDbOptions.connectionString = readString(env, 'PGVECTOR_CONNECTION_STRING') ?? readString(env, 'POSTGRES_URL') ?? (base?.vectorDb?.options?.connectionString as string) ?? '';
80
+ vectorDbOptions.tables = (readString(env, 'VECTOR_DB_TABLES') ?? readString(env, 'POSTGRES_TABLES'))?.split(',').map(t => t.trim()) ?? (base?.vectorDb?.options?.tables as string[]);
81
+ vectorDbOptions.searchFields = readString(env, 'POSTGRES_SEARCH_FIELDS')?.split(',').map(f => f.trim()) ?? (base?.vectorDb?.options?.searchFields as string[]);
80
82
  vectorDbOptions.dimensions = embeddingDimensions;
81
83
  } else if (vectorProvider === 'mongodb') {
82
84
  vectorDbOptions.uri = readString(env, 'MONGODB_URI') ?? (base?.vectorDb?.options?.uri as string) ?? '';
@@ -14,6 +14,7 @@ import { EmbeddingStrategyResolver } from '../config/EmbeddingStrategy';
14
14
  import { QueryProcessor } from './QueryProcessor';
15
15
  import { IngestDocument, ChatResponse, VectorMatch, GraphSearchResult, RetrievalResult, UpsertDocument } from '../types';
16
16
  import { UITransformer } from '../utils/UITransformer';
17
+ import { SchemaMapper, SchemaMap } from '../utils/SchemaMapper';
17
18
 
18
19
  // ─── LRU Embedding Cache ───────────────────────────────────────────────────────
19
20
 
@@ -97,209 +98,36 @@ export class Pipeline {
97
98
  this.reranker = new Reranker();
98
99
  }
99
100
 
101
+ /**
102
+ * Expose the underlying LLM provider (set after initialize()).
103
+ * Used by the stream handler to pass to UITransformer.analyzeAndDecide().
104
+ */
105
+ getLLMProvider(): ILLMProvider | undefined {
106
+ return this.initialised ? this.llmProvider : undefined;
107
+ }
108
+
100
109
  async initialize(): Promise<void> {
101
110
  if (this.initialised) return;
102
111
 
103
112
  // Augment system prompt with a Universal Visualization Protocol
104
113
  // We use a unique marker to ensure this is only injected once but is ALWAYS present.
105
- const CHART_MARKER = '<!-- UI_PROTOCOL_V7 -->';
114
+ //const CHART_MARKER = '<!-- UI_PROTOCOL_V7 -->';
106
115
  const chartInstruction = `
107
-
108
- ${CHART_MARKER}
109
- ### UNIVERSAL UI PROTOCOL V7 (STRICT MODE)
110
-
111
- You are responsible for returning a SINGLE structured UI block when visualization is required.
112
-
113
- ---
114
-
115
- ## 1. INTENT CLASSIFICATION (MANDATORY)
116
-
117
- Classify the query into ONE of the following intents:
118
-
119
- - "explore" → user wants to browse items (e.g., "show me products")
120
- - "analyze" → user wants aggregation/distribution (e.g., "distribution of products")
121
- - "compare" → user wants side-by-side comparison
122
- - "detail" → user wants structured/tabular data
123
-
124
- ---
125
-
126
- ## 2. VIEW MAPPING (STRICT)
127
-
128
- | Intent | View |
129
- |----------|-----------|
130
- | explore | carousel |
131
- | analyze | chart |
132
- | compare | table |
133
- | detail | table |
134
-
135
- ⚠️ Override Rules:
136
- - If query contains "distribution", "percentage", "ratio" → ALWAYS chart
137
- - If query contains "show products", "list items" → ALWAYS carousel
138
- - NEVER mix views
139
-
140
- ---
141
-
142
- ## 3. RESPONSE FORMAT (STRICT JSON ONLY INSIDE BLOCK)
143
-
144
- \`\`\`ui
145
- {
146
- "view": "carousel" | "chart" | "table",
147
- "title": "string",
148
- "description": "string",
149
- "metadata": {
150
- "intent": "explore" | "analyze" | "compare" | "detail",
151
- "confidence": 0.0-1.0
152
- },
153
-
154
- // CHART ONLY
155
- "chart": {
156
- "type": "pie" | "bar" | "line",
157
- "xKey": "label",
158
- "yKeys": ["value"],
159
- "data": []
160
- },
161
-
162
- // TABLE ONLY
163
- "table": {
164
- "columns": [],
165
- "rows": []
166
- },
167
-
168
- // CAROUSEL ONLY
169
- "carousel": {
170
- "items": []
171
- },
172
-
173
- "insights": []
174
- }
175
- \`\`\`
176
-
177
- ---
178
-
179
- ## 4. DATA CONTRACT (CRITICAL)
180
-
181
- ### 🔹 CAROUSEL ITEM FORMAT
182
- {
183
- "id": "string",
184
- "name": "string",
185
- "brand": "string",
186
- "price": number,
187
- "image": "url",
188
- "link": "url",
189
- "inStock": boolean
190
- }
191
-
192
- ### 🔹 CHART DATA FORMAT
193
- {
194
- "label": "string",
195
- "value": number,
196
- "inStock": boolean (optional)
197
- }
198
-
199
- ### 🔹 TABLE FORMAT
200
- - columns MUST match row keys
201
- - rows MUST be flat objects
202
-
203
- ---
204
-
205
- ## 5. DATA RULES
206
-
207
- - NEVER hallucinate fields or data
208
- - ONLY use retrieved vector DB data
209
- - NO placeholders ("...", "N/A")
210
- - If image/link missing → REMOVE FIELD
211
- - Aggregate BEFORE rendering chart
212
- - IF NO DATA IS FOUND in the retrieved context:
213
- → DO NOT output a \`\`\`ui\`\`\` block
214
- → Instead, state clearly that no relevant products or data were found.
215
-
216
- ---
217
-
218
- ## 6. SMART RULES (IMPORTANT)
219
-
220
- - If user asks BOTH:
221
- "show products AND distribution"
222
- → PRIORITIZE "explore" → carousel
223
-
224
- - If dataset size > 20:
225
- → aggregate → chart or table
226
-
227
- ---
228
-
229
- ## 7. OUTPUT RULES
230
-
231
- - ALWAYS return EXACTLY ONE \`\`\`ui\`\`\` block
232
- - JSON must be VALID
233
- - No explanation inside block
234
- - NEVER use ASCII charts, Markdown tables, or text-based drawings for data.
235
- - If data is tabular or statistical, ALWAYS use the \`\`\`ui\`\`\` block.
236
-
237
- ---
238
-
239
- ## 8. EXAMPLES
240
-
241
- User: "show me beauty products"
242
-
243
- \`\`\`ui
244
- {
245
- "view": "carousel",
246
- "title": "Beauty Products",
247
- "description": "Browse available beauty items",
248
- "metadata": {
249
- "intent": "explore",
250
- "confidence": 0.95
251
- },
252
- "carousel": {
253
- "items": [
254
- {
255
- "id": "1",
256
- "name": "Mascara",
257
- "brand": "Essence",
258
- "price": 9.99,
259
- "image": "https://example.com/mascara.jpg",
260
- "link": "https://example.com/p1",
261
- "inStock": true
262
- }
263
- ]
264
- },
265
- "insights": ["Most products are affordable"]
266
- }
267
- \`\`\`
268
-
269
- ---
270
-
271
- User: "distribution of products across categories"
272
-
273
- \`\`\`ui
274
- {
275
- "view": "chart",
276
- "title": "Product Distribution",
277
- "description": "Category-wise product distribution",
278
- "metadata": {
279
- "intent": "analyze",
280
- "confidence": 0.98
281
- },
282
- "chart": {
283
- "type": "pie",
284
- "xKey": "label",
285
- "yKeys": ["value"],
286
- "data": [
287
- { "label": "Beauty", "value": 15 },
288
- { "label": "Electronics", "value": 10 }
289
- ]
290
- },
291
- "insights": ["Beauty dominates inventory"]
292
- }
293
- \`\`\`
116
+ You are a helpful product assistant. Use the provided context to answer questions accurately.
117
+
118
+ ### UI STYLE RULES (CRITICAL):
119
+ - NEVER generate markdown tables. If you do, the UI will break.
120
+ - NEVER generate HTML tags like <figure>, <tbody>, <tr>, etc.
121
+ - NEVER generate text-based charts or graphs.
122
+ - ONLY use plain text and bullet points.
123
+
124
+ ### PRODUCT DISPLAY:
125
+ - When recommending products, simply list their names, prices, and features in a friendly, conversational manner.
126
+ - The UI will automatically detect these products and show high-quality product cards in a carousel below your message.
127
+ - Do NOT try to format product lists as tables.
294
128
  `;
295
- if (!this.config.llm.systemPrompt?.includes(CHART_MARKER)) {
296
- // Clean up ANY existing protocol markers (V1-V7) to avoid prompt bloat
297
- let cleanPrompt = this.config.llm.systemPrompt || '';
298
- cleanPrompt = cleanPrompt.replace(/\n\n### UNIVERSAL UI PROTOCOL[\s\S]*?(?=\n\n|$)/g, '');
299
- cleanPrompt = cleanPrompt.replace(/<!-- UI_PROTOCOL_V\d+ -->[\s\S]*?(?=\n\n|$)/g, '');
300
-
301
- this.config.llm.systemPrompt = cleanPrompt.trim() + chartInstruction;
302
- }
129
+
130
+ this.config.llm.systemPrompt = chartInstruction;
303
131
 
304
132
  console.log(`[Pipeline] Initializing with provider: ${this.config.vectorDb.provider}...`);
305
133
  // Resolve vector DB provider
@@ -487,16 +315,18 @@ User: "distribution of products across categories"
487
315
  let sources: VectorMatch[] = [];
488
316
  let graphData: GraphSearchResult | undefined;
489
317
 
318
+ let uiTransformation: unknown;
490
319
  for await (const chunk of stream) {
491
320
  if (typeof chunk === 'string') {
492
321
  reply += chunk;
493
- } else if ('sources' in chunk) {
494
- sources = chunk.sources;
495
- graphData = chunk.graphData;
322
+ } else if (typeof chunk === 'object' && chunk !== null) {
323
+ if ('sources' in chunk) sources = chunk.sources;
324
+ if ('graphData' in chunk) graphData = chunk.graphData;
325
+ if ('ui_transformation' in chunk) uiTransformation = chunk.ui_transformation;
496
326
  }
497
327
  }
498
328
 
499
- return { reply, sources, graphData };
329
+ return { reply, sources, graphData, ui_transformation: uiTransformation };
500
330
  }
501
331
 
502
332
  /**
@@ -549,8 +379,17 @@ User: "distribution of products across categories"
549
379
  context = `GRAPH KNOWLEDGE:\n${graphContext}\n\nVECTOR CONTEXT:\n${context}`;
550
380
  }
551
381
 
382
+ // 4.5 Schema Training (Dynamic Discovery) - Start training but don't block the stream yet
383
+ const allMetadataKeys = Array.from(new Set(sources.flatMap(s => Object.keys(s.metadata || {}))));
384
+ const trainingPromise = allMetadataKeys.length > 0
385
+ ? SchemaMapper.train(this.llmProvider, ns, allMetadataKeys)
386
+ : Promise.resolve(undefined);
387
+
552
388
  // 5. Generation (Streaming)
553
- const messages: ChatMessage[] = [...history, { role: 'user', content: question }];
389
+ const restrictionSuffix = "\n\n(IMPORTANT: Use plain text only. NEVER generate tables, HTML figures, or text charts. If listing products, use simple bullet points.)";
390
+ const hardenedHistory = [...history];
391
+ const userQuestion = { role: 'user', content: question + restrictionSuffix } as ChatMessage;
392
+ const messages: ChatMessage[] = [...hardenedHistory, userQuestion];
554
393
 
555
394
  if (this.llmProvider.chatStream) {
556
395
  const stream = this.llmProvider.chatStream(messages, context);
@@ -566,16 +405,17 @@ User: "distribution of products across categories"
566
405
  }
567
406
 
568
407
  // 6. Automated UI Fallback (UITransformer)
569
- // This provides a secondary, rule-based visualization frame in case the LLM
570
- // fails to adhere to the strict protocol or outputs a text fallback.
571
- const uiTransformation = UITransformer.transform(question, sources);
408
+ // Wait for training to finish if it hasn't already
409
+ const trainedSchema = await trainingPromise;
410
+
411
+ const uiTransformation = await this.generateUiTransformation(question, sources, context, trainedSchema);
572
412
 
573
413
  // Yield retrieval metadata and automated UI transformation at the end
574
- yield {
575
- reply: '',
576
- sources,
577
- graphData,
578
- ui_transformation: uiTransformation
414
+ yield {
415
+ reply: '',
416
+ sources,
417
+ graphData,
418
+ ui_transformation: uiTransformation
579
419
  } as ChatResponse;
580
420
 
581
421
  } catch (error) {
@@ -587,6 +427,27 @@ User: "distribution of products across categories"
587
427
  * Universal retrieval method combining all enabled providers.
588
428
  * Uses an LRU-bounded embedding cache to avoid re-embedding the same query.
589
429
  */
430
+ private async generateUiTransformation(
431
+ question: string,
432
+ sources: VectorMatch[],
433
+ _context: string,
434
+ trainedSchema?: SchemaMap
435
+ ): Promise<unknown> {
436
+ if (!sources || sources.length === 0) {
437
+ return UITransformer.transform(question, sources, this.config, trainedSchema);
438
+ }
439
+
440
+ try {
441
+ // Delegate to the centralized LLM-driven visualization decision.
442
+ // UITransformer.analyzeAndDecide() internally falls back to the keyword
443
+ // heuristic if the LLM call fails or returns unparseable JSON.
444
+ return await UITransformer.analyzeAndDecide(question, sources, this.llmProvider);
445
+ } catch (err) {
446
+ console.warn('[Pipeline] generateUiTransformation failed, using heuristic fallback:', err);
447
+ return UITransformer.transform(question, sources, this.config, trainedSchema);
448
+ }
449
+ }
450
+
590
451
  async retrieve(query: string, options: { namespace?: string; topK?: number; filter?: Record<string, unknown> }): Promise<RetrievalResult> {
591
452
  const ns = options.namespace ?? this.config.projectId;
592
453
  const topK = options.topK ?? 5;
@@ -77,8 +77,8 @@ export class ProviderRegistry {
77
77
  }
78
78
  case 'pgvector':
79
79
  case 'postgresql': {
80
- const { PostgreSQLProvider } = await import('../providers/vectordb/PostgreSQLProvider');
81
- return PostgreSQLProvider;
80
+ const { MultiTablePostgresProvider } = await import('../providers/vectordb/MultiTablePostgresProvider');
81
+ return MultiTablePostgresProvider as unknown as VectorProviderClass;
82
82
  }
83
83
  case 'mongodb': {
84
84
  const { MongoDBProvider } = await import('../providers/vectordb/MongoDBProvider');
@@ -38,6 +38,15 @@ export class VectorPlugin {
38
38
  return this.config;
39
39
  }
40
40
 
41
+ /**
42
+ * Get the initialized LLM provider (available after the first request).
43
+ * Used by handlers to pass to UITransformer.analyzeAndDecide() for
44
+ * LLM-driven visualization decisions.
45
+ */
46
+ getLLMProvider() {
47
+ return this.pipeline.getLLMProvider();
48
+ }
49
+
41
50
  /**
42
51
  * Perform pre-flight health checks on all configured providers.
43
52
  * Useful to verify connectivity before running operations.
@@ -27,7 +27,7 @@ function sseMetaFrame(meta: unknown): string {
27
27
 
28
28
  /** Encode the UI transformation result as an SSE frame. */
29
29
  function sseUIFrame(uiTransformation: unknown): string {
30
- return `data: ${JSON.stringify({ type: 'ui_transformation', ...((uiTransformation as object) ?? {}) })}\n\n`;
30
+ return `data: ${JSON.stringify({ type: 'ui_transformation', data: uiTransformation })}\n\n`;
31
31
  }
32
32
 
33
33
  /** Encode a stream error as an SSE frame. */
@@ -152,15 +152,31 @@ export function createStreamHandler(configOrPlugin?: Partial<RagConfig> | Vector
152
152
  // Retrieval metadata object — always the final frame
153
153
  enqueue(sseMetaFrame(chunk));
154
154
 
155
- // Transform retrieved sources to UI representation
156
- const sources = (chunk as ChatResponse)?.sources || [];
157
- if (sources && sources.length > 0) {
155
+ // ── LLM-driven visualization decision ─────────────────────────
156
+ // Sends question + RAG context to the LLM and asks it to choose
157
+ // the best visualization format (bar_chart, pie_chart, table…).
158
+ // Falls back to keyword heuristic if the LLM call fails.
159
+ const responseChunk = chunk as ChatResponse;
160
+ const sources = responseChunk?.sources || [];
161
+
162
+ if (sources.length > 0) {
158
163
  try {
159
- const uiTransformation = UITransformer.transform(message, sources);
160
- enqueue(sseUIFrame(uiTransformation));
164
+ const llmProvider = plugin.getLLMProvider?.();
165
+ const uiTransformation = responseChunk?.ui_transformation ??
166
+ (llmProvider
167
+ ? await UITransformer.analyzeAndDecide(message, sources, llmProvider)
168
+ : UITransformer.transform(message, sources, plugin.getConfig()));
169
+
170
+ if (uiTransformation) {
171
+ enqueue(sseUIFrame(uiTransformation));
172
+ }
161
173
  } catch (transformError) {
162
174
  console.warn('[createStreamHandler] UI transformation warning:', transformError);
163
- // Don't fail the stream if transformation fails, just skip it
175
+ // Last-resort fallback: heuristic transform
176
+ try {
177
+ const fallback = UITransformer.transform(message, sources, plugin.getConfig());
178
+ if (fallback) enqueue(sseUIFrame(fallback));
179
+ } catch { /* silent */ }
164
180
  }
165
181
  }
166
182
  }
@@ -155,7 +155,7 @@ export function useRagChat(
155
155
  buffer = buffer.slice(lastBoundary + 2);
156
156
 
157
157
  for (const frame of parseSseChunk(complete)) {
158
- if (frame.type === 'text') {
158
+ if (frame.type === 'text' && frame.text) {
159
159
  assistantContent += frame.text;
160
160
  } else if (frame.type === 'metadata') {
161
161
  sources = frame.sources ?? [];
@@ -184,7 +184,7 @@ export function useRagChat(
184
184
  // Process any remaining buffered data
185
185
  if (buffer.trim()) {
186
186
  for (const frame of parseSseChunk(buffer)) {
187
- if (frame.type === 'text') assistantContent += frame.text;
187
+ if (frame.type === 'text' && frame.text) assistantContent += frame.text;
188
188
  else if (frame.type === 'metadata') sources = frame.sources ?? [];
189
189
  }
190
190
  }
@@ -12,10 +12,50 @@ interface LLMProviderStatic {
12
12
  getHealthChecker?: () => IProviderHealthChecker;
13
13
  }
14
14
 
15
+ /** Registry for custom provider factories registered at runtime. */
16
+ const customProviders = new Map<string, (config: LLMConfig) => ILLMProvider>();
17
+
15
18
  /**
16
19
  * LLMFactory — instantiates the correct ILLMProvider based on llmConfig.provider.
17
20
  */
18
21
  export class LLMFactory {
22
+ /**
23
+ * Register a custom LLM provider factory at runtime.
24
+ *
25
+ * Use this to add support for any LLM backend (Azure OpenAI, Cohere, Mistral,
26
+ * Bedrock, etc.) without modifying this library's source code.
27
+ *
28
+ * @example
29
+ * // In your Next.js app initialization:
30
+ * import { LLMFactory } from '@retrivora-ai/rag-engine/server';
31
+ * import { MyCustomProvider } from './providers/MyCustomProvider';
32
+ *
33
+ * LLMFactory.register('my-provider', (config) => new MyCustomProvider(config));
34
+ *
35
+ * // Then set in your .env.local:
36
+ * // LLM_PROVIDER=my-provider
37
+ */
38
+ static register(name: string, factory: (config: LLMConfig) => ILLMProvider): void {
39
+ customProviders.set(name.toLowerCase(), factory);
40
+ console.log(`[LLMFactory] Registered custom provider: "${name}"`);
41
+ }
42
+
43
+ /**
44
+ * Unregister a previously registered custom provider.
45
+ */
46
+ static unregister(name: string): void {
47
+ customProviders.delete(name.toLowerCase());
48
+ }
49
+
50
+ /**
51
+ * List all registered provider names (built-in + custom).
52
+ */
53
+ static listProviders(): string[] {
54
+ return [
55
+ 'openai', 'anthropic', 'ollama', 'gemini', 'rest', 'universal_rest', 'custom',
56
+ ...Array.from(customProviders.keys()),
57
+ ];
58
+ }
19
59
  static create(llmConfig: LLMConfig, embeddingConfig?: EmbeddingConfig): ILLMProvider {
20
60
  switch (llmConfig.provider) {
21
61
  case 'openai':
@@ -30,11 +70,23 @@ export class LLMFactory {
30
70
  case 'universal_rest':
31
71
  case 'custom':
32
72
  return new UniversalLLMAdapter(llmConfig);
33
- default:
73
+ default: {
74
+ // Check the runtime custom provider registry first
75
+ const providerName = String(llmConfig.provider ?? '').toLowerCase();
76
+ const customFactory = customProviders.get(providerName);
77
+ if (customFactory) {
78
+ return customFactory(llmConfig);
79
+ }
80
+ // Fall back to UniversalLLMAdapter if a baseUrl is provided
34
81
  if (llmConfig.baseUrl || (llmConfig.options as Record<string, unknown>)?.baseUrl) {
35
82
  return new UniversalLLMAdapter(llmConfig);
36
83
  }
37
- throw new Error(`[LLMFactory] Unknown provider "${llmConfig.provider}"`);
84
+ throw new Error(
85
+ `[LLMFactory] Unknown provider "${llmConfig.provider}". ` +
86
+ `Built-in providers: ${LLMFactory.listProviders().join(', ')}. ` +
87
+ `Register a custom provider with LLMFactory.register().`
88
+ );
89
+ }
38
90
  }
39
91
  }
40
92
 
@@ -75,13 +75,15 @@ export class AnthropicProvider implements ILLMProvider {
75
75
  }
76
76
 
77
77
  async chat(messages: ChatMessage[], context: string, options?: ChatOptions): Promise<string> {
78
- const systemPrompt =
78
+ const resolvedPrompt = options?.systemPrompt ??
79
79
  this.llmConfig.systemPrompt ??
80
80
  `You are a helpful assistant. Use the context below to answer the user's question accurately.\n\nContext:\n${context}`;
81
81
 
82
- const system = systemPrompt.includes('{{context}}')
83
- ? systemPrompt.replace('{{context}}', context)
84
- : `${systemPrompt}\n\nContext:\n${context}`;
82
+ const system = resolvedPrompt.includes('{{context}}')
83
+ ? resolvedPrompt.replace('{{context}}', context)
84
+ : options?.systemPrompt
85
+ ? resolvedPrompt // per-call: context is already in the user message
86
+ : `${resolvedPrompt}\n\nContext:\n${context}`;
85
87
 
86
88
  const anthropicMessages: Anthropic.MessageParam[] = messages.map((m) => ({
87
89
  role: m.role === 'assistant' ? 'assistant' : 'user',
@@ -100,13 +102,15 @@ export class AnthropicProvider implements ILLMProvider {
100
102
  }
101
103
 
102
104
  async *chatStream(messages: ChatMessage[], context: string, options?: ChatOptions): AsyncIterable<string> {
103
- const systemPrompt =
105
+ const resolvedPrompt = options?.systemPrompt ??
104
106
  this.llmConfig.systemPrompt ??
105
107
  `You are a helpful assistant. Use the context below to answer the user's question accurately.\n\nContext:\n${context}`;
106
108
 
107
- const system = systemPrompt.includes('{{context}}')
108
- ? systemPrompt.replace('{{context}}', context)
109
- : `${systemPrompt}\n\nContext:\n${context}`;
109
+ const system = resolvedPrompt.includes('{{context}}')
110
+ ? resolvedPrompt.replace('{{context}}', context)
111
+ : options?.systemPrompt
112
+ ? resolvedPrompt
113
+ : `${resolvedPrompt}\n\nContext:\n${context}`;
110
114
 
111
115
  const anthropicMessages: Anthropic.MessageParam[] = messages.map((m) => ({
112
116
  role: m.role === 'assistant' ? 'assistant' : 'user',