@retrivora-ai/rag-engine 1.8.0 → 1.8.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.
- package/dist/DocumentChunker-Dh9TvmGG.d.mts +45 -0
- package/dist/DocumentChunker-Dh9TvmGG.d.ts +45 -0
- package/dist/{index-DPsQodME.d.mts → ILLMProvider-BOJFz3Na.d.mts} +104 -1
- package/dist/{index-DPsQodME.d.ts → ILLMProvider-BOJFz3Na.d.ts} +104 -1
- package/dist/MultiTablePostgresProvider-ZLGSKTJR.mjs +8 -0
- package/dist/chunk-ICKRMZQK.mjs +76 -0
- package/dist/{chunk-PV3MFHWU.mjs → chunk-LZVVLSDN.mjs} +977 -516
- package/dist/chunk-OZFBG4BA.mjs +291 -0
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +1269 -656
- package/dist/handlers/index.mjs +4 -1
- package/dist/{index-Bb2yEopi.d.mts → index-BwpcaziY.d.ts} +10 -2
- package/dist/{index-CkbTzj9J.d.ts → index-D3V9Et2M.d.mts} +10 -2
- package/dist/index.d.mts +24 -4
- package/dist/index.d.ts +24 -4
- package/dist/index.js +1354 -826
- package/dist/index.mjs +1284 -795
- package/dist/server.d.mts +47 -27
- package/dist/server.d.ts +47 -27
- package/dist/server.js +1417 -829
- package/dist/server.mjs +164 -176
- package/package.json +6 -2
- package/src/app/api/upload/route.ts +4 -0
- package/src/app/constants.tsx +2 -2
- package/src/app/page.tsx +12 -322
- package/src/components/AmbientBackground.tsx +29 -0
- package/src/components/ArchitectureCard.tsx +17 -0
- package/src/components/ArchitectureCardsSection.tsx +15 -0
- package/src/components/ChatWindow.tsx +32 -0
- package/src/components/CodeViewer.tsx +51 -0
- package/src/components/ConfigProvider.tsx +1 -0
- package/src/components/DocViewer.tsx +37 -0
- package/src/components/DocumentUpload.tsx +44 -1
- package/src/components/Documentation.tsx +58 -0
- package/src/components/DynamicChart.tsx +27 -2
- package/src/components/Hero.tsx +59 -0
- package/src/components/HourglassLoader.tsx +87 -0
- package/src/components/Lifecycle.tsx +37 -0
- package/src/components/MarkdownComponents.tsx +140 -0
- package/src/components/MessageBubble.tsx +124 -904
- package/src/components/Navbar.tsx +55 -0
- package/src/components/ObservabilityPanel.tsx +374 -0
- package/src/components/ProductCard.tsx +5 -3
- package/src/components/UIDispatcher.tsx +344 -0
- package/src/components/VisualizationRenderer.tsx +372 -250
- package/src/config/RagConfig.ts +5 -0
- package/src/config/serverConfig.ts +3 -1
- package/src/core/Pipeline.ts +240 -271
- package/src/core/ProviderRegistry.ts +2 -2
- package/src/core/VectorPlugin.ts +9 -0
- package/src/handlers/index.ts +91 -15
- package/src/hooks/useRagChat.ts +21 -11
- package/src/index.ts +9 -1
- package/src/llm/LLMFactory.ts +54 -2
- package/src/llm/providers/AnthropicProvider.ts +12 -8
- package/src/llm/providers/GeminiProvider.ts +188 -143
- package/src/llm/providers/OllamaProvider.ts +7 -3
- package/src/llm/providers/OpenAIProvider.ts +12 -8
- package/src/providers/vectordb/MultiTablePostgresProvider.ts +150 -64
- package/src/types/chat.ts +8 -0
- package/src/types/index.ts +132 -0
- package/src/types/props.ts +9 -1
- package/src/utils/ProductExtractor.ts +347 -0
- package/src/utils/SchemaMapper.ts +129 -0
- package/src/utils/UITransformer.ts +470 -209
- package/src/utils/synonyms.ts +78 -0
- package/dist/DocumentChunker-C1GEEosY.d.ts +0 -93
- package/dist/DocumentChunker-CFEiRopR.d.mts +0 -93
- package/dist/PostgreSQLProvider-BMOETDZA.mjs +0 -8
- package/dist/chunk-FLOSGE6A.mjs +0 -202
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { Product, VectorMatch } from '../types';
|
|
2
|
+
import { resolveMetadataValue } from './synonyms';
|
|
3
|
+
|
|
4
|
+
// ─── JSON sanitization ────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export function sanitizeJson(raw: string): string {
|
|
7
|
+
let s = raw.trim();
|
|
8
|
+
|
|
9
|
+
// 1. Evaluate simple arithmetic expressions in values e.g. "v": 495 / 1000
|
|
10
|
+
s = s.replace(
|
|
11
|
+
/:\s*(-?\d+(?:\.\d+)?)\s*([+\-*/])\s*(-?\d+(?:\.\d+)?)/g,
|
|
12
|
+
(_, a, op, b) => {
|
|
13
|
+
const n1 = parseFloat(a);
|
|
14
|
+
const n2 = parseFloat(b);
|
|
15
|
+
const result =
|
|
16
|
+
op === '+' ? n1 + n2
|
|
17
|
+
: op === '-' ? n1 - n2
|
|
18
|
+
: op === '*' ? n1 * n2
|
|
19
|
+
: n2 !== 0 ? n1 / n2
|
|
20
|
+
: n1;
|
|
21
|
+
return `: ${result}`;
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// 2. Strip JS-style comments while preserving strings (e.g. URLs)
|
|
26
|
+
s = s.replace(/("(?:\\.|[^\\"])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, g1) => g1 || '');
|
|
27
|
+
|
|
28
|
+
// 3. Quote bare (unquoted) object keys
|
|
29
|
+
s = s.replace(/([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/g, '$1"$2":');
|
|
30
|
+
|
|
31
|
+
// 4. Replace single-quoted strings with double-quoted ones
|
|
32
|
+
s = s.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, '"$1"');
|
|
33
|
+
|
|
34
|
+
// 5. Remove trailing commas before ] or }
|
|
35
|
+
s = s.replace(/,\s*([}\]])/g, '$1');
|
|
36
|
+
|
|
37
|
+
// 6. Remove completely empty key-value pairs left by prior cleanup
|
|
38
|
+
s = s.replace(/([{,])\s*""\s*(?=[,}])/g, '$1');
|
|
39
|
+
|
|
40
|
+
// 7. Stack-based structure balancer.
|
|
41
|
+
const stack: ('{' | '[')[] = [];
|
|
42
|
+
let inString = false;
|
|
43
|
+
let escape = false;
|
|
44
|
+
|
|
45
|
+
for (const ch of s) {
|
|
46
|
+
if (escape) { escape = false; continue; }
|
|
47
|
+
if (ch === '\\' && inString) { escape = true; continue; }
|
|
48
|
+
if (ch === '"') { inString = !inString; continue; }
|
|
49
|
+
if (inString) continue;
|
|
50
|
+
|
|
51
|
+
if (ch === '{' || ch === '[') {
|
|
52
|
+
stack.push(ch);
|
|
53
|
+
} else if (ch === '}') {
|
|
54
|
+
if (stack[stack.length - 1] === '{') stack.pop();
|
|
55
|
+
} else if (ch === ']') {
|
|
56
|
+
if (stack[stack.length - 1] === '[') stack.pop();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (inString) s += '"';
|
|
61
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
62
|
+
s += stack[i] === '{' ? '}' : ']';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const lastClose = Math.max(s.lastIndexOf('}'), s.lastIndexOf(']'));
|
|
66
|
+
if (lastClose !== -1 && lastClose < s.length - 1 && /\S/.test(s.slice(lastClose + 1))) {
|
|
67
|
+
s = s.substring(0, lastClose + 1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return s;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function extractLikelyJsonBlock(raw: string): string {
|
|
74
|
+
const trimmed = raw.trim();
|
|
75
|
+
const objectStart = trimmed.indexOf('{');
|
|
76
|
+
const arrayStart = trimmed.indexOf('[');
|
|
77
|
+
|
|
78
|
+
const starts = [objectStart, arrayStart].filter((index) => index >= 0);
|
|
79
|
+
if (starts.length === 0) return trimmed;
|
|
80
|
+
|
|
81
|
+
return trimmed.slice(Math.min(...starts));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function looksLikeStructuredPayload(raw: string): boolean {
|
|
85
|
+
return /"?(view|chartType|type|data|items|rows|products|results)"?\s*:/.test(raw);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function stripMarkdownTables(raw: string): string {
|
|
89
|
+
return raw.replace(
|
|
90
|
+
/(?:^|\n)\|[^\n]+\|\n\|(?:\s*:?-+:?\s*\|)+\n(?:\|[^\n]*\|\n?)*/g,
|
|
91
|
+
'\n\n',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function stripStructuredUiLabels(raw: string): string {
|
|
96
|
+
return raw.replace(
|
|
97
|
+
/(?:^|\n)\*\*(?:Chart Data|Table Data|Visualization|Visual Representation)\*\:\s*(?=\n|$)/gi,
|
|
98
|
+
'\n',
|
|
99
|
+
).replace(
|
|
100
|
+
/(?:^|\n)(?:Chart Data|Table Data|Visualization|Visual Representation)\s*\:\s*(?=\n|$)/gi,
|
|
101
|
+
'\n',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extractBareJsonObject(raw: string): string | null {
|
|
106
|
+
let start = raw.indexOf('{');
|
|
107
|
+
if (start === -1) return null;
|
|
108
|
+
|
|
109
|
+
let bestJson: string | null = null;
|
|
110
|
+
let maxLen = 0;
|
|
111
|
+
|
|
112
|
+
while (start !== -1) {
|
|
113
|
+
let depth = 0;
|
|
114
|
+
let inString = false;
|
|
115
|
+
let escape = false;
|
|
116
|
+
|
|
117
|
+
for (let i = start; i < raw.length; i += 1) {
|
|
118
|
+
const ch = raw[i];
|
|
119
|
+
if (escape) { escape = false; continue; }
|
|
120
|
+
if (ch === '\\' && inString) { escape = true; continue; }
|
|
121
|
+
if (ch === '"') { inString = !inString; continue; }
|
|
122
|
+
if (inString) continue;
|
|
123
|
+
|
|
124
|
+
if (ch === '{') depth += 1;
|
|
125
|
+
else if (ch === '}') {
|
|
126
|
+
depth -= 1;
|
|
127
|
+
if (depth === 0) {
|
|
128
|
+
const candidate = raw.slice(start, i + 1);
|
|
129
|
+
if (candidate.length > maxLen) {
|
|
130
|
+
maxLen = candidate.length;
|
|
131
|
+
bestJson = candidate;
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
start = raw.indexOf('{', start + 1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return bestJson;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function extractStructuredPayload(raw: string): { payload: string | null; text: string } {
|
|
144
|
+
let working = raw;
|
|
145
|
+
const fenceRegex = /```(?:[a-z]+)?\s*([\s\S]*?)```/gi;
|
|
146
|
+
|
|
147
|
+
for (const match of Array.from(raw.matchAll(fenceRegex))) {
|
|
148
|
+
const body = match[1]?.trim() ?? '';
|
|
149
|
+
if (!looksLikeStructuredPayload(body)) continue;
|
|
150
|
+
|
|
151
|
+
working = working.replace(match[0], '');
|
|
152
|
+
return {
|
|
153
|
+
payload: body,
|
|
154
|
+
text: stripStructuredUiLabels(working).replace(/\n{3,}/g, '\n\n').trim(),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const bareJson = extractBareJsonObject(raw);
|
|
159
|
+
if (bareJson && looksLikeStructuredPayload(bareJson)) {
|
|
160
|
+
working = working.replace(bareJson, '');
|
|
161
|
+
return {
|
|
162
|
+
payload: bareJson,
|
|
163
|
+
text: stripStructuredUiLabels(working).replace(/\n{3,}/g, '\n\n').trim(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
payload: null,
|
|
169
|
+
text: stripStructuredUiLabels(raw).replace(/\n{3,}/g, '\n\n').trim(),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function resolveImage(data: Record<string, unknown>): string | undefined {
|
|
174
|
+
const isPlaceholder = (val: unknown) =>
|
|
175
|
+
typeof val === 'string' && (val === '...' || val === '' || val.toLowerCase() === 'null' || val.toLowerCase() === 'undefined');
|
|
176
|
+
|
|
177
|
+
for (const key of ['image', 'img', 'thumbnail', 'images', 'product_image', 'Image', 'Thumbnail']) {
|
|
178
|
+
const v = data[key];
|
|
179
|
+
if (typeof v === 'string' && v && !isPlaceholder(v)) return v;
|
|
180
|
+
if (Array.isArray(v) && typeof v[0] === 'string' && v[0] && !isPlaceholder(v[0])) return v[0];
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function isLikelyUiOnlyMessage(text: string): boolean {
|
|
186
|
+
const trimmed = text.trim();
|
|
187
|
+
return (
|
|
188
|
+
trimmed.length === 0 ||
|
|
189
|
+
trimmed.length < 30 ||
|
|
190
|
+
/^(chart data|table data|visualization|visual representation|product catalog|product recommendations|catalog summary|availability|inventory status|details)\s*:?\s*$/i.test(trimmed)
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── Extractors ───────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
export function extractProductsFromSources(sources: VectorMatch[] | undefined, isUser: boolean): Product[] {
|
|
197
|
+
if (isUser || !sources) return [];
|
|
198
|
+
return sources
|
|
199
|
+
.filter(s => {
|
|
200
|
+
const m = s.metadata ?? {};
|
|
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)
|
|
204
|
+
);
|
|
205
|
+
const hasPricePattern = /\$\s*\d+/.test(s.content);
|
|
206
|
+
return hasProductKey || hasPricePattern || m.type === 'product';
|
|
207
|
+
})
|
|
208
|
+
.map(s => {
|
|
209
|
+
const m = (s.metadata ?? {}) as Record<string, unknown>;
|
|
210
|
+
const name = resolveMetadataValue(m, 'name') as string;
|
|
211
|
+
const brand = resolveMetadataValue(m, 'brand') as string;
|
|
212
|
+
const price = resolveMetadataValue(m, 'price') as string | number;
|
|
213
|
+
const description = resolveMetadataValue(m, 'description') as string;
|
|
214
|
+
console.log("🚀 ~ extractProductsFromSources ~ description:", s)
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
id: s.id,
|
|
218
|
+
name: name ?? s.content.split('\n')[0] ?? 'Unknown Product',
|
|
219
|
+
brand: brand,
|
|
220
|
+
price: price,
|
|
221
|
+
image: resolveImage(m),
|
|
222
|
+
link: resolveMetadataValue(m, 'link') as string,
|
|
223
|
+
description: description ? description : '' //description ?? s.content,
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function extractProductsFromContent(
|
|
229
|
+
content: string,
|
|
230
|
+
payloadText: string,
|
|
231
|
+
payloadCandidates: string[],
|
|
232
|
+
productsFromSources: Product[]
|
|
233
|
+
): { productsFromContent: Product[]; cleanContent: string } {
|
|
234
|
+
const jsonRegex = /```(?:json|ui)?\s*([\s\S]*?)\s*```/g;
|
|
235
|
+
const products: Product[] = [];
|
|
236
|
+
let cleanContent = payloadText;
|
|
237
|
+
|
|
238
|
+
for (const candidate of payloadCandidates) {
|
|
239
|
+
try {
|
|
240
|
+
const data = JSON.parse(sanitizeJson(candidate));
|
|
241
|
+
const itemSet =
|
|
242
|
+
Array.isArray(data.data) ? data.data
|
|
243
|
+
: Array.isArray(data.items) ? data.items
|
|
244
|
+
: Array.isArray(data.products) ? data.products
|
|
245
|
+
: null;
|
|
246
|
+
|
|
247
|
+
if (
|
|
248
|
+
(data.type === 'products' || data.view === 'carousel' || data.view === 'table' || Array.isArray(data.products)) &&
|
|
249
|
+
itemSet
|
|
250
|
+
) {
|
|
251
|
+
products.push(
|
|
252
|
+
...itemSet.map((item: Record<string, unknown>) => ({
|
|
253
|
+
...item,
|
|
254
|
+
image: (item.image as string) ?? resolveImage(item),
|
|
255
|
+
})) as Product[],
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
// Ignore malformed product payloads
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (cleanContent.includes('"type": "products"') || cleanContent.includes('"type":"products"')) {
|
|
264
|
+
for (const match of Array.from(cleanContent.matchAll(jsonRegex))) {
|
|
265
|
+
try {
|
|
266
|
+
const data = JSON.parse(sanitizeJson(match[1]));
|
|
267
|
+
if (data.type === 'products' && Array.isArray(data.items)) {
|
|
268
|
+
products.push(
|
|
269
|
+
...data.items.map((item: Record<string, unknown>) => ({
|
|
270
|
+
...item,
|
|
271
|
+
image: (item.image as string) ?? resolveImage(item),
|
|
272
|
+
})) as Product[],
|
|
273
|
+
);
|
|
274
|
+
cleanContent = cleanContent.replace(match[0], '');
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
// Malformed product block — skip silently
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Fallback: Scan for plain text bulleted products
|
|
283
|
+
// Requires a bullet or number at the start of the line to prevent matching plain text paragraphs.
|
|
284
|
+
const bulletRegex = /^[ \t]*(?:[•*-]|\d+\.)\s+(?:\*\*)?([^\n\-\$–—\(]+?)(?:\*\*)?(?:\s*\(?Price\s*[:\-–—]?\s*)?(?:\s*[:\-–—]\s*|\s+)\$?([\d,.]+)(?:\s*USD)?/gim;
|
|
285
|
+
const matches = Array.from(cleanContent.matchAll(bulletRegex));
|
|
286
|
+
|
|
287
|
+
if (matches.length >= 2) {
|
|
288
|
+
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
289
|
+
|
|
290
|
+
for (const match of matches) {
|
|
291
|
+
let name = match[1]?.trim() || '';
|
|
292
|
+
name = name.replace(/\s*\(?Price\s*$/i, '').replace(/[:\-–—]\s*$/g, '').trim();
|
|
293
|
+
name = name.replace(/^\*\*|\*\*$/g, '').replace(/^\*|\*$/g, '').trim();
|
|
294
|
+
|
|
295
|
+
let price = match[2]?.trim() || '';
|
|
296
|
+
price = price.replace(/[,\s]+$/, '').trim();
|
|
297
|
+
|
|
298
|
+
if (name && price) {
|
|
299
|
+
const newProduct: Product = {
|
|
300
|
+
id: `text-prod-${name}-${price}`,
|
|
301
|
+
name,
|
|
302
|
+
price: `$${price}`,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const normName = normalize(name);
|
|
306
|
+
const sourceMatch = productsFromSources.find(s => {
|
|
307
|
+
const sn = normalize(s.name);
|
|
308
|
+
return sn.includes(normName) || normName.includes(sn);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (sourceMatch) {
|
|
312
|
+
newProduct.image = sourceMatch.image;
|
|
313
|
+
newProduct.brand = sourceMatch.brand;
|
|
314
|
+
newProduct.link = sourceMatch.link;
|
|
315
|
+
newProduct.description = sourceMatch.description;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
products.push(newProduct);
|
|
319
|
+
cleanContent = cleanContent.replace(match[0], `\n**${name}** — $${price}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
cleanContent = cleanContent.replace(/\n{3,}/g, '\n\n').trim();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return { productsFromContent: products, cleanContent: cleanContent.trim() };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function deduplicateProducts(
|
|
329
|
+
productsFromSources: Product[],
|
|
330
|
+
productsFromContent: Product[],
|
|
331
|
+
messageContent: string
|
|
332
|
+
): Product[] {
|
|
333
|
+
if (productsFromContent.length > 0) return productsFromContent;
|
|
334
|
+
|
|
335
|
+
const seen = new Set<string>();
|
|
336
|
+
const contentLower = messageContent.toLowerCase();
|
|
337
|
+
|
|
338
|
+
return productsFromSources.filter(p => {
|
|
339
|
+
const id = String(p.id ?? p.name);
|
|
340
|
+
if (seen.has(id)) return false;
|
|
341
|
+
const mentioned =
|
|
342
|
+
contentLower.includes(p.name.toLowerCase()) ||
|
|
343
|
+
(p.brand ? contentLower.includes(p.brand.toLowerCase()) : false);
|
|
344
|
+
if (mentioned) { seen.add(id); return true; }
|
|
345
|
+
return false;
|
|
346
|
+
});
|
|
347
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { ILLMProvider } from '../llm/ILLMProvider';
|
|
2
|
+
|
|
3
|
+
export interface SchemaMap {
|
|
4
|
+
name?: string;
|
|
5
|
+
price?: string;
|
|
6
|
+
brand?: string;
|
|
7
|
+
image?: string;
|
|
8
|
+
stock?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
category?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* SchemaMapper — Uses AI to "train" the plugin on unknown database schemas.
|
|
15
|
+
* It maps arbitrary metadata keys to standard UI properties.
|
|
16
|
+
*/
|
|
17
|
+
export class SchemaMapper {
|
|
18
|
+
private static cache = new Map<string, SchemaMap>();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Descriptions of standard UI properties to help the AI map fields accurately.
|
|
22
|
+
*/
|
|
23
|
+
private static readonly TARGET_PROPERTIES: Record<string, string> = {
|
|
24
|
+
name: 'The primary title, name, or label of the item',
|
|
25
|
+
price: 'The numeric cost, price, MSRP, or amount',
|
|
26
|
+
brand: 'The manufacturer, vendor, brand, or maker',
|
|
27
|
+
image: 'The URL to an image, thumbnail, photo, or picture',
|
|
28
|
+
stock: 'The availability, inventory count, quantity, or stock level',
|
|
29
|
+
description: 'The detailed text content, summary, or body info',
|
|
30
|
+
category: 'The group, department, type, or category name',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Trains the plugin on a set of keys.
|
|
35
|
+
* This is done once per schema and cached.
|
|
36
|
+
*/
|
|
37
|
+
static async train(
|
|
38
|
+
llm: ILLMProvider,
|
|
39
|
+
projectId: string,
|
|
40
|
+
keys: string[]
|
|
41
|
+
): Promise<SchemaMap> {
|
|
42
|
+
const cacheKey = `${projectId}:${keys.sort().join(',')}`;
|
|
43
|
+
if (this.cache.has(cacheKey)) {
|
|
44
|
+
return this.cache.get(cacheKey)!;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log(`[SchemaMapper] 🧠 Training AI on new schema keys: ${keys.join(', ')}`);
|
|
48
|
+
|
|
49
|
+
// Dynamically build the property list for the prompt
|
|
50
|
+
const propertyList = Object.entries(this.TARGET_PROPERTIES)
|
|
51
|
+
.map(([prop, desc]) => `- ${prop} (${desc})`)
|
|
52
|
+
.join('\n');
|
|
53
|
+
|
|
54
|
+
const prompt = `
|
|
55
|
+
Given these metadata keys from a database: [${keys.join(', ')}]
|
|
56
|
+
|
|
57
|
+
Identify which keys best correspond to these standard UI properties:
|
|
58
|
+
${propertyList}
|
|
59
|
+
|
|
60
|
+
Return ONLY a JSON object where the keys are the UI properties and the values are the matching database keys.
|
|
61
|
+
If no good match is found for a property, omit it.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
{
|
|
65
|
+
"name": "Product_Title",
|
|
66
|
+
"price": "MSRP_USD",
|
|
67
|
+
"brand": "VendorName"
|
|
68
|
+
}
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const response = await llm.chat(
|
|
73
|
+
[
|
|
74
|
+
{ role: 'system', content: 'You are a database schema expert. You ONLY respond with valid JSON.' },
|
|
75
|
+
{ role: 'user', content: prompt }
|
|
76
|
+
],
|
|
77
|
+
''
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Extract the first balanced JSON object from the response
|
|
81
|
+
const startIdx = response.indexOf('{');
|
|
82
|
+
if (startIdx !== -1) {
|
|
83
|
+
let braceCount = 0;
|
|
84
|
+
let endIdx = -1;
|
|
85
|
+
|
|
86
|
+
for (let i = startIdx; i < response.length; i++) {
|
|
87
|
+
if (response[i] === '{') braceCount++;
|
|
88
|
+
else if (response[i] === '}') {
|
|
89
|
+
braceCount--;
|
|
90
|
+
if (braceCount === 0) {
|
|
91
|
+
endIdx = i;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (endIdx !== -1) {
|
|
98
|
+
const jsonContent = response.substring(startIdx, endIdx + 1);
|
|
99
|
+
const cleanJson = this.sanitizeJson(jsonContent);
|
|
100
|
+
const mapping = JSON.parse(cleanJson) as SchemaMap;
|
|
101
|
+
this.cache.set(cacheKey, mapping);
|
|
102
|
+
return mapping;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.warn('[SchemaMapper] AI training failed, falling back to heuristics:', error);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Forgiving JSON parser that fixes common AI formatting mistakes.
|
|
114
|
+
*/
|
|
115
|
+
private static sanitizeJson(s: string): string {
|
|
116
|
+
return s
|
|
117
|
+
.replace(/\/\*[\s\S]*?\*\/|([^:]|^)\/\/.*$/gm, '') // Remove comments
|
|
118
|
+
.replace(/[\u201C\u201D\u2018\u2019]/g, '"') // Replace smart quotes
|
|
119
|
+
.replace(/'/g, '"') // Replace single quotes
|
|
120
|
+
.replace(/([{,]\s*)([a-zA-Z0-9_]+?)\s*:/g, '$1"$2":') // Add quotes to keys
|
|
121
|
+
.replace(/,\s*([}\]])/g, '$1') // Remove trailing commas
|
|
122
|
+
.trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static getCached(projectId: string, keys: string[]): SchemaMap | undefined {
|
|
126
|
+
const cacheKey = `${projectId}:${keys.sort().join(',')}`;
|
|
127
|
+
return this.cache.get(cacheKey);
|
|
128
|
+
}
|
|
129
|
+
}
|