Spaces:
Sleeping
Sleeping
| /** | |
| * 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 []; | |
| } | |
| } | |