cellex-web / src /lib /ai.ts
eeshaAI
feat: Migrate frontend from static HTML to Next.js 16 App Router
a09d2a4
Raw
History Blame Contribute Delete
25.6 kB
/**
* AI Infrastructure Configuration
*
* Central config for NVIDIA NIM, Chroma Vector DB, and Gorse Recommender.
* All keys are read from environment variables (set in Render dashboard).
*/
// === NVIDIA NIM API ===
export const NVIDIA_API_KEY = process.env.NVIDIA_API_KEY || '';
export const NVIDIA_BASE_URL = 'https://integrate.api.nvidia.com/v1';
// NVIDIA model endpoints (optimized for speed)
export const NVIDIA_MODELS = {
// Text embeddings for semantic search (1024-dim).
// NOTE: 'nvidia/embed-qa-4' is not enabled on the current NVIDIA account,
// so we use 'nvidia/nv-embedqa-e5-v5' which is enabled and produces the same
// 1024-dim vectors — drop-in replacement.
textEmbedding: 'nvidia/nv-embedqa-e5-v5',
// Multimodal vision-language model for image-to-product search
multimodal: 'nvidia/neva-22b',
// LLM for generating search context/summaries
llm: 'meta/llama-3.1-70b-instruct',
} as const;
// Cache the collection id from Chroma (v1 API addresses collections by id, not name).
let cachedChromaCollectionId: string | null = null;
// === Chroma Vector DB ===
export const CHROMA_URL = process.env.CHROMA_URL || 'http://localhost:8000';
export const CHROMA_COLLECTION = 'cellex_products';
// === Gorse Recommender System ===
export const GORSE_URL = process.env.GORSE_URL || 'http://localhost:8088';
export const GORSE_API_KEY = process.env.GORSE_API_KEY || '';
// === Performance Targets ===
export const PERF = {
targetResponseMs: 3000, // 3 second overall target
nvidiaTimeoutMs: 2000, // NVIDIA API timeout
chromaTimeoutMs: 1000, // Chroma query timeout
gorseTimeoutMs: 1000, // Gorse recommendation timeout
supabaseTimeoutMs: 1000, // Supabase hydration timeout
} as const;
/**
* Generate a text embedding using NVIDIA NIM (embed-qa-4).
* Returns a 1024-dimensional float array.
*/
export async function generateTextEmbedding(text: string): Promise<number[]> {
if (!NVIDIA_API_KEY) {
console.warn('[AI] NVIDIA_API_KEY not set, skipping embedding');
return [];
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PERF.nvidiaTimeoutMs);
try {
const resp = await fetch(`${NVIDIA_BASE_URL}/embeddings`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${NVIDIA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: NVIDIA_MODELS.textEmbedding,
input: text,
input_type: 'query',
encoding_format: 'float',
}),
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) {
console.error('[AI] NVIDIA embedding error:', resp.status, await resp.text());
return [];
}
const data = await resp.json();
return data.data?.[0]?.embedding || [];
} catch (err) {
clearTimeout(timeout);
console.error('[AI] NVIDIA embedding failed:', err);
return [];
}
}
/**
* Generate a multimodal embedding using NVIDIA NeVA-22B.
* Accepts an image URL and optional text prompt, returns a description/embedding.
*/
export async function generateImageEmbedding(imageUrl: string, prompt?: string): Promise<{ description: string; embedding: number[] }> {
if (!NVIDIA_API_KEY) {
return { description: '', embedding: [] };
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PERF.nvidiaTimeoutMs);
try {
const resp = await fetch(`${NVIDIA_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${NVIDIA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: NVIDIA_MODELS.multimodal,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt || 'Describe this product for e-commerce search. Include category, color, material, and key features.' },
{ type: 'image_url', image_url: { url: imageUrl } },
],
},
],
max_tokens: 200,
temperature: 0.3,
}),
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) {
console.error('[AI] NVIDIA NeVA error:', resp.status);
return { description: '', embedding: [] };
}
const data = await resp.json();
const description = data.choices?.[0]?.message?.content || '';
// Generate embedding from the description
const embedding = await generateTextEmbedding(description);
return { description, embedding };
} catch (err) {
clearTimeout(timeout);
console.error('[AI] NVIDIA NeVA failed:', err);
return { description: '', embedding: [] };
}
}
/**
* Resolve the Chroma collection id for CHROMA_COLLECTION.
* Chroma v1 API addresses collections by id, not name, so we list collections
* once, find ours by name, and cache the id. If the collection doesn't exist,
* we create it (so first-run works without manual setup).
*/
async function ensureChromaCollectionId(): Promise<string | null> {
if (cachedChromaCollectionId) return cachedChromaCollectionId;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PERF.chromaTimeoutMs);
try {
const listResp = await fetch(`${CHROMA_URL}/api/v1/collections`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (!listResp.ok) {
console.error('[AI] Chroma list collections error:', listResp.status);
return null;
}
const collections = await listResp.json();
const found = (collections as Array<{ id: string; name: string }>).find(
(c) => c.name === CHROMA_COLLECTION,
);
if (found) {
cachedChromaCollectionId = found.id;
return found.id;
}
// Not found — create it
const createResp = await fetch(`${CHROMA_URL}/api/v1/collections`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: CHROMA_COLLECTION }),
});
if (!createResp.ok) {
console.error('[AI] Chroma create collection error:', createResp.status);
return null;
}
const created = await createResp.json();
cachedChromaCollectionId = created.id;
return created.id;
} catch (err) {
clearTimeout(timeout);
console.error('[AI] Chroma collection resolution failed:', err);
return null;
}
}
/**
* Query Chroma Vector DB for similar product IDs.
* Uses Chroma v1 API (collections addressed by id).
* Returns array of { id, score } pairs.
*/
export async function queryChroma(embedding: number[], limit: number = 20): Promise<Array<{ id: string; score: number }>> {
if (!embedding.length) return [];
const collectionId = await ensureChromaCollectionId();
if (!collectionId) return [];
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PERF.chromaTimeoutMs);
try {
const resp = await fetch(`${CHROMA_URL}/api/v1/collections/${collectionId}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query_embeddings: [embedding],
n_results: limit,
include: ['distances', 'documents', 'metadatas'],
}),
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) {
console.error('[AI] Chroma query error:', resp.status);
return [];
}
const data = await resp.json();
const ids = data.ids?.[0] || [];
const distances = data.distances?.[0] || [];
return ids.map((id: string, i: number) => ({
id,
score: 1 - (distances[i] || 0), // Convert distance to similarity score
}));
} catch (err) {
clearTimeout(timeout);
console.error('[AI] Chroma query failed:', err);
return [];
}
}
/**
* Fetch personalized recommendations from Gorse.
* Returns array of product IDs ranked by relevance.
*/
export async function getGorseRecommendations(
userId: string,
options: { category?: string; limit?: number; page?: number } = {}
): Promise<string[]> {
const { category, limit = 20, page = 0 } = options;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PERF.gorseTimeoutMs);
try {
// If category is specified, use category-aware recommendation
const endpoint = category
? `${GORSE_URL}/api/recommend/${userId}?n=${limit}&offset=${page * limit}&categories=${encodeURIComponent(category)}`
: `${GORSE_URL}/api/recommend/${userId}?n=${limit}&offset=${page * limit}`;
const resp = await fetch(endpoint, {
headers: GORSE_API_KEY ? { 'Api-Key': GORSE_API_KEY } : {},
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) {
console.error('[AI] Gorse recommend error:', resp.status);
return [];
}
const data = await resp.json();
return data.Items || data.items || [];
} catch (err) {
clearTimeout(timeout);
console.error('[AI] Gorse recommend failed:', err);
return [];
}
}
/**
* Get item-to-item neighbors (for "Users also viewed" section).
*/
export async function getGorseItemNeighbors(itemId: string, limit: number = 10): Promise<string[]> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PERF.gorseTimeoutMs);
try {
const resp = await fetch(`${GORSE_URL}/api/item/${itemId}/neighbors?n=${limit}`, {
headers: GORSE_API_KEY ? { 'Api-Key': GORSE_API_KEY } : {},
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) return [];
const data = await resp.json();
return data.Items || data.items || [];
} catch (err) {
clearTimeout(timeout);
return [];
}
}
/**
* Send feedback to Gorse (likes, clicks, views, purchases).
* Non-blocking — fire and forget.
*/
export async function sendGorseFeedback(
userId: string,
itemId: string,
feedbackType: 'like' | 'click' | 'view' | 'purchase' | 'skip' | 'replay',
score?: number
): Promise<void> {
if (!GORSE_URL) return;
const scoreMap: Record<string, number> = {
like: 1,
click: 0.5,
view: 0.3,
purchase: 2,
skip: -0.1,
replay: 0.8,
};
const payload = {
Feedback: [{
UserId: userId,
ItemId: itemId,
FeedbackType: feedbackType,
Timestamp: new Date().toISOString(),
Score: score ?? scoreMap[feedbackType] ?? 0.5,
}],
};
// Fire and forget — don't await, don't block
fetch(`${GORSE_URL}/api/feedback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(GORSE_API_KEY ? { 'Api-Key': GORSE_API_KEY } : {}),
},
body: JSON.stringify(payload),
}).catch(() => {}); // Silently ignore errors
}
// ============================================================================
// Chroma embed/sync utilities (incremental — used by product create/update/delete)
// ============================================================================
/**
* Generate a "passage" embedding for a product (used when STORING in Chroma).
* The query-time embedding (input_type='query') is generated by generateTextEmbedding().
*/
export async function generateProductPassageEmbedding(text: string): Promise<number[]> {
if (!NVIDIA_API_KEY) return [];
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const resp = await fetch(`${NVIDIA_BASE_URL}/embeddings`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${NVIDIA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: NVIDIA_MODELS.textEmbedding,
input: text,
input_type: 'passage',
encoding_format: 'float',
}),
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) {
console.error('[AI] NVIDIA passage embedding error:', resp.status, await resp.text());
return [];
}
const data = await resp.json();
return data.data?.[0]?.embedding || [];
} catch (err) {
clearTimeout(timeout);
console.error('[AI] NVIDIA passage embedding failed:', err);
return [];
}
}
/**
* Build the searchable text for a product (name + category + description).
* Used both at seed time and at incremental-sync time so the text is consistent.
*/
export function buildProductSearchText(p: {
name?: string | null;
category?: string | null;
description?: string | null;
}): string {
return [p.name, p.category, p.description]
.filter((s) => s && String(s).trim())
.map((s) => String(s).trim())
.join(' ');
}
/**
* Add (or update) a single product's embedding in Chroma.
* Called when a seller creates or updates a product.
* Uses Chroma v1 API: POST /api/v1/collections/{id}/add (upsert semantics).
*
* Non-throwing — logs errors and returns boolean.
*/
export async function upsertProductToChroma(
productId: string | number,
product: { name?: string | null; category?: string | null; description?: string | null; price?: number | string | null; image_url?: string | null },
): Promise<boolean> {
if (!NVIDIA_API_KEY) {
console.warn('[AI] upsertProductToChroma: NVIDIA_API_KEY not set, skipping');
return false;
}
const text = buildProductSearchText(product);
if (!text) {
console.warn(`[AI] upsertProductToChroma: empty text for product ${productId}, skipping`);
return false;
}
const embedding = await generateProductPassageEmbedding(text);
if (!embedding.length) {
console.error(`[AI] upsertProductToChroma: failed to embed product ${productId}`);
return false;
}
const collectionId = await ensureChromaCollectionId();
if (!collectionId) {
console.error('[AI] upsertProductToChroma: no Chroma collection id');
return false;
}
const metadata = {
product_id: String(productId),
name: product.name || '',
price: String(product.price ?? 0),
category: product.category || '',
image_url: product.image_url || '',
text,
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PERF.chromaTimeoutMs);
try {
const resp = await fetch(`${CHROMA_URL}/api/v1/collections/${collectionId}/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ids: [String(productId)],
embeddings: [embedding],
metadatas: [metadata],
documents: [text],
}),
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) {
console.error(`[AI] Chroma upsert error for product ${productId}:`, resp.status);
return false;
}
return true;
} catch (err) {
clearTimeout(timeout);
console.error(`[AI] Chroma upsert failed for product ${productId}:`, err);
return false;
}
}
/**
* Delete a single product's embedding from Chroma.
* Called when a seller deletes a product.
*/
export async function deleteProductFromChroma(productId: string | number): Promise<boolean> {
const collectionId = await ensureChromaCollectionId();
if (!collectionId) return false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PERF.chromaTimeoutMs);
try {
const resp = await fetch(`${CHROMA_URL}/api/v1/collections/${collectionId}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [String(productId)] }),
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) {
console.error(`[AI] Chroma delete error for product ${productId}:`, resp.status);
return false;
}
return true;
} catch (err) {
clearTimeout(timeout);
console.error(`[AI] Chroma delete failed for product ${productId}:`, err);
return false;
}
}
// ============================================================================
// Real in-process ranking (used when Gorse is not configured / returns nothing)
// Combines: Chroma semantic similarity (for personalization) + real Supabase
// engagement metrics (units_sold, views_count) for trending. No fake math.
// ============================================================================
/**
* Fetch all product IDs+engagement metrics from Supabase via the management SQL API.
* Returns rows sorted by a real engagement score (descending).
*
* Engagement score (computed from REAL tables, no fake math):
* units_sold * 4 — sales are the strongest signal
* + view_count * 0.5 — from product_view_log
* + wishlist_count * 3 — from buyers_wishlist (strong intent)
* + review_count * 2 — from buyers_reviews (engagement)
* + recency_bonus (50 if <7d, 20 if <30d)
*/
export async function fetchRealProductRankingFromSupabase(limit: number): Promise<Array<{
id: string;
score: number;
units_sold: number;
views_count: number;
created_at: string;
}>> {
const SUPABASE_TOKEN = process.env.SUPABASE_TOKEN || process.env.SUPABASE_SERVICE_KEY || '';
const PROJECT = process.env.SUPABASE_PROJECT || 'tcwdbokruvlizkxcpkzj';
if (!SUPABASE_TOKEN) return [];
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PERF.supabaseTimeoutMs);
try {
const resp = await fetch(`https://api.supabase.com/v1/projects/${PROJECT}/database/query`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${SUPABASE_TOKEN}`,
'Content-Type': 'application/json',
'User-Agent': 'cellex-recommend',
},
body: JSON.stringify({
query: `
WITH view_counts AS (
SELECT product_id, COUNT(*) AS view_count
FROM product_view_log
GROUP BY product_id
),
wishlist_counts AS (
SELECT product_id, COUNT(*) AS wishlist_count
FROM buyers_wishlist
GROUP BY product_id
),
review_counts AS (
SELECT product_id, COUNT(*) AS review_count
FROM buyers_reviews
GROUP BY product_id
)
SELECT p.id,
COALESCE(p.units_sold, 0) AS units_sold,
COALESCE(vc.view_count, 0) AS views_count,
COALESCE(wc.wishlist_count, 0) AS wishlist_count,
COALESCE(rc.review_count, 0) AS review_count,
p.created_at,
(COALESCE(p.units_sold, 0) * 4
+ COALESCE(vc.view_count, 0) * 0.5
+ COALESCE(wc.wishlist_count, 0) * 3
+ COALESCE(rc.review_count, 0) * 2
+ CASE WHEN p.created_at > NOW() - INTERVAL '7 days' THEN 50
WHEN p.created_at > NOW() - INTERVAL '30 days' THEN 20
ELSE 0 END) AS score
FROM products p
LEFT JOIN view_counts vc ON vc.product_id = p.id
LEFT JOIN wishlist_counts wc ON wc.product_id = p.id
LEFT JOIN review_counts rc ON rc.product_id = p.id
ORDER BY score DESC
LIMIT ${Math.min(limit * 4, 200)};
`.trim(),
}),
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) {
console.error('[AI] Supabase ranking query error:', resp.status);
return [];
}
const data = await resp.json();
if (!Array.isArray(data)) return [];
return data.map((r: any) => ({
id: String(r.id),
score: Number(r.score) || 0,
units_sold: Number(r.units_sold) || 0,
views_count: Number(r.views_count) || 0,
created_at: r.created_at,
}));
} catch (err) {
clearTimeout(timeout);
console.error('[AI] Supabase ranking query failed:', err);
return [];
}
}
/**
* Fetch the user's recently viewed/liked/saved product IDs from Supabase.
* Used as the basis for Chroma similarity-based personalization.
*
* Unions real engagement tables: product_view_log, buyers_wishlist, buyers_reviews.
* Returns product IDs ordered by signal strength (review > wishlist > view) and recency.
*/
export async function fetchUserFeedbackHistory(userId: string, limit = 20): Promise<string[]> {
const SUPABASE_TOKEN = process.env.SUPABASE_TOKEN || process.env.SUPABASE_SERVICE_KEY || '';
const PROJECT = process.env.SUPABASE_PROJECT || 'tcwdbokruvlizkxcpkzj';
if (!SUPABASE_TOKEN || !userId || userId === 'anonymous') return [];
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PERF.supabaseTimeoutMs);
const safeUserId = userId.replace(/'/g, "''");
try {
const resp = await fetch(`https://api.supabase.com/v1/projects/${PROJECT}/database/query`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${SUPABASE_TOKEN}`,
'Content-Type': 'application/json',
'User-Agent': 'cellex-recommend',
},
body: JSON.stringify({
query: `
(
SELECT product_id AS item_id, 'review' AS signal, created_at
FROM buyers_reviews WHERE user_id = '${safeUserId}'
)
UNION ALL
(
SELECT product_id AS item_id, 'wishlist' AS signal, created_at
FROM buyers_wishlist WHERE user_id = '${safeUserId}'
)
UNION ALL
(
SELECT product_id AS item_id, 'view' AS signal, created_at
FROM product_view_log WHERE user_id = '${safeUserId}'
)
ORDER BY
CASE signal WHEN 'review' THEN 0 WHEN 'wishlist' THEN 1 ELSE 2 END,
created_at DESC
LIMIT ${limit};
`.trim(),
}),
signal: controller.signal,
});
clearTimeout(timeout);
if (!resp.ok) return [];
const data = await resp.json();
if (!Array.isArray(data)) return [];
// Dedupe — keep first occurrence (highest-ranked signal)
const seen = new Set<string>();
const out: string[] = [];
for (const r of data) {
const id = String(r.item_id);
if (id && !seen.has(id)) {
seen.add(id);
out.push(id);
}
}
return out;
} catch (err) {
clearTimeout(timeout);
return [];
}
}
/**
* Get personalized recommendations using Chroma semantic similarity.
* For each item the user has liked/viewed recently, find similar items in Chroma,
* dedupe, then return ranked IDs.
*
* This is REAL personalization (not hardcoded) — driven by NVIDIA embeddings + Chroma.
*/
export async function getChromaPersonalizedRecommendations(
userId: string,
limit: number,
): Promise<string[]> {
const historyIds = await fetchUserFeedbackHistory(userId, 10);
if (!historyIds.length) return [];
// For each history item, fetch its embedding from Chroma, then query for neighbors.
// Limit to first 5 history items to stay fast.
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PERF.chromaTimeoutMs * 3);
const collectionId = await ensureChromaCollectionId();
if (!collectionId) return [];
const seen = new Set<string>(historyIds);
const ranked: Array<{ id: string; score: number }> = [];
try {
// Query Chroma for each history item, in parallel (limited concurrency)
const histories = historyIds.slice(0, 5);
const neighborsPerItem = Math.max(8, Math.ceil(limit / histories.length));
const results = await Promise.all(
histories.map(async (hid) => {
// Get the item's own embedding via Chroma's "get" endpoint
const getResp = await fetch(
`${CHROMA_URL}/api/v1/collections/${collectionId}/get?ids=${encodeURIComponent(JSON.stringify([hid]))}&include=embeddings`,
{ signal: controller.signal },
).catch(() => null);
if (!getResp || !getResp.ok) return [];
const getData = await getResp.json();
const emb = getData.embeddings?.[0];
if (!emb || !Array.isArray(emb)) return [];
// Query for similar items
const queryResp = await fetch(`${CHROMA_URL}/api/v1/collections/${collectionId}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query_embeddings: [emb],
n_results: neighborsPerItem + 1, // +1 because the item itself will be in results
include: ['distances'],
}),
signal: controller.signal,
});
if (!queryResp.ok) return [];
const queryData = await queryResp.json();
const ids: string[] = queryData.ids?.[0] || [];
const distances: number[] = queryData.distances?.[0] || [];
return ids.map((id, i) => ({ id, score: 1 - (distances[i] || 0) })).filter((x) => x.id !== hid);
}),
);
for (const neighbors of results) {
for (const n of neighbors) {
if (seen.has(n.id)) continue;
const existing = ranked.find((r) => r.id === n.id);
if (existing) {
existing.score = Math.max(existing.score, n.score);
} else {
ranked.push({ id: n.id, score: n.score });
}
}
}
clearTimeout(timeout);
ranked.sort((a, b) => b.score - a.score);
return ranked.slice(0, limit).map((r) => r.id);
} catch (err) {
clearTimeout(timeout);
console.error('[AI] Chroma personalized recommendations failed:', err);
return [];
}
}