import { NextRequest, NextResponse } from 'next/server'; import { api, API_BASE } from '@/lib/api'; import { getGorseRecommendations, getGorseItemNeighbors, sendGorseFeedback, GORSE_URL, fetchRealProductRankingFromSupabase, getChromaPersonalizedRecommendations, upsertProductToChroma, deleteProductFromChroma, } from '@/lib/ai'; const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || ''; const SUPABASE_TOKEN = process.env.SUPABASE_TOKEN || process.env.SUPABASE_SERVICE_KEY || ''; const EDGE_FUNCTIONS_URL = 'https://tcwdbokruvlizkxcpkzj.supabase.co/functions/v1'; const COOKIE_NAME = 'cellex_session_id'; const PROJECT = 'tcwdbokruvlizkxcpkzj'; /** * Recommendation API — Dynamic AI-driven feeds (replaces hard-coded feeds) * * POST /api/recommend * Body: { * op: 'home' | 'category' | 'shorts' | 'neighbors' | 'feedback' * | 'product_embed' | 'product_delete', * userId?: string, * category?: string, * itemId?: string, * limit?: number, * feedback?: { itemId, type, score? }, * product?: { id, name, category, description, price, image_url }, // for product_embed * productId?: string | number, // for product_delete * } * * Ranking strategy (in priority order): * 1. If GORSE_URL is configured AND returns IDs → use Gorse (collaborative filtering) * 2. Else if user is logged in AND has engagement history → use Chroma semantic similarity * (find products similar to what they've viewed/liked/saved) * 3. Else → use real trending score from Supabase * (units_sold*4 + views*0.5 + wishlist*3 + reviews*2 + recency bonus) * * No more silent Supabase "fallback" that masks a missing Gorse deployment. * The source field in the response tells you which path was used. */ export async function POST(request: NextRequest) { if (!SUPABASE_ANON_KEY) { return NextResponse.json({ success: false, error: 'SUPABASE_ANON_KEY not set' }, { status: 500 }); } const sessionId = request.cookies.get(COOKIE_NAME)?.value || ''; let body: any; try { body = await request.json(); } catch { return NextResponse.json({ success: false, error: 'Invalid JSON' }, { status: 400 }); } // === AUTH === let userId = ''; if (sessionId) { try { const authResp = await fetch(`${EDGE_FUNCTIONS_URL}/auth`, { method: 'POST', headers: { 'apikey': SUPABASE_ANON_KEY, 'Authorization': `Bearer ${sessionId}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ op: 'session' }), }); const authData = await authResp.json(); if (authData.success && authData.user) { userId = authData.user.id; } } catch {} } const effectiveUserId = body.userId || userId || 'anonymous'; switch (body.op) { case 'home': return await handleHome(effectiveUserId, body.limit || 20); case 'category': return await handleCategory(effectiveUserId, body.category || '', body.limit || 30); case 'shorts': return await handleShorts(effectiveUserId, body.limit || 15); case 'neighbors': return await handleNeighbors(body.itemId || '', body.limit || 10); case 'feedback': return await handleFeedback(effectiveUserId, body.feedback); case 'product_embed': return await handleProductEmbed(body.product); case 'product_delete': return await handleProductDelete(body.productId); default: return NextResponse.json({ success: false, error: `Unknown op: ${body.op}` }, { status: 400 }); } } /** * Homepage Feed — REAL AI-driven ranking, no hardcoded Supabase fallback. * * Strategy: * 1. Gorse (if configured) — collaborative filtering across all users * 2. Chroma personalization (if logged-in user has engagement history) — * semantic similarity to products they've viewed/liked/saved * 3. Real trending (always available) — Supabase engagement score */ async function handleHome(userId: string, limit: number) { const startTime = Date.now(); const sources: string[] = []; // 1. Try Gorse first (only if configured — no silent fallback) if (GORSE_URL && GORSE_URL !== 'http://localhost:8088') { const gorseIds = await getGorseRecommendations(userId, { limit }); if (gorseIds.length > 0) { const hydrated = await hydrateProducts(gorseIds); if (hydrated.length > 0) { return NextResponse.json({ success: true, source: 'gorse', products: hydrated, latencyMs: Date.now() - startTime, }); } } sources.push('gorse:empty'); } // 2. Try Chroma personalization (real AI — NVIDIA embeddings + similarity) if (userId && userId !== 'anonymous') { const chromaIds = await getChromaPersonalizedRecommendations(userId, limit); if (chromaIds.length > 0) { const hydrated = await hydrateProducts(chromaIds); if (hydrated.length > 0) { return NextResponse.json({ success: true, source: 'chroma-personalized', products: hydrated, latencyMs: Date.now() - startTime, }); } } sources.push('chroma:empty-or-no-history'); } // 3. Real trending — Supabase engagement score (units_sold, views, wishlist, reviews) const ranked = await fetchRealProductRankingFromSupabase(limit); if (ranked.length > 0) { const hydrated = await hydrateProducts(ranked.map((r) => r.id)); // Attach real engagement scores to the hydrated products const scoreMap = new Map(ranked.map((r) => [r.id, r])); const enriched = hydrated.map((p: any) => ({ ...p, _engagement_score: scoreMap.get(String(p.id))?.score || 0, _views_count: scoreMap.get(String(p.id))?.views_count || 0, })); return NextResponse.json({ success: true, source: 'trending-real', products: enriched, latencyMs: Date.now() - startTime, debug: { sourcesTried: sources }, }); } // 4. Last resort — return empty (DO NOT silently fall back to a hardcoded list) return NextResponse.json({ success: true, source: 'empty', products: [], latencyMs: Date.now() - startTime, debug: { sourcesTried: sources }, }); } /** * Category Page Feed — blend category filters with personalization */ async function handleCategory(userId: string, category: string, limit: number) { const startTime = Date.now(); if (GORSE_URL && GORSE_URL !== 'http://localhost:8088') { const gorseIds = await getGorseRecommendations(userId, { category, limit }); if (gorseIds.length > 0) { const hydrated = await hydrateProducts(gorseIds); if (hydrated.length > 0) { return NextResponse.json({ success: true, source: 'gorse', products: hydrated, latencyMs: Date.now() - startTime, }); } } } // Fallback: real category products from Supabase (filtered by category, ranked by engagement) const ranked = await fetchRealProductRankingFromSupabase(limit * 3); const rankedIds = ranked.map((r) => r.id); if (rankedIds.length > 0) { const hydrated = await hydrateProducts(rankedIds); // Filter by category and re-rank by engagement score const scoreMap = new Map(ranked.map((r) => [r.id, r])); const filtered = hydrated .filter((p: any) => (p.category || '').toLowerCase() === (category || '').toLowerCase()) .map((p: any) => ({ ...p, _engagement_score: scoreMap.get(String(p.id))?.score || 0, })) .slice(0, limit); if (filtered.length > 0) { return NextResponse.json({ success: true, source: 'category-real', products: filtered, latencyMs: Date.now() - startTime, }); } } return NextResponse.json({ success: true, source: 'empty', products: [], latencyMs: Date.now() - startTime, }); } /** * Shorts Page Feed — hyper-engaging video content, personalized */ async function handleShorts(userId: string, limit: number) { const startTime = Date.now(); if (GORSE_URL && GORSE_URL !== 'http://localhost:8088') { const gorseIds = await getGorseRecommendations(userId, { limit }); if (gorseIds.length > 0) { const hydrated = await hydrateVideos(gorseIds); if (hydrated.length > 0) { return NextResponse.json({ success: true, source: 'gorse', videos: hydrated, latencyMs: Date.now() - startTime, }); } } } // Fallback: existing Supabase video feed (real videos, ranked by recency) const fallbackResp = await fetch(`${EDGE_FUNCTIONS_URL}/videos`, { method: 'POST', headers: { 'apikey': SUPABASE_ANON_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ op: 'feed', limit }), }).then((r) => r.json()).catch(() => ({ success: false })); return NextResponse.json({ ...fallbackResp, source: 'videos-feed-real', latencyMs: Date.now() - startTime, }); } /** * Product Detail "Users Also Viewed" — item-to-item collaborative filtering */ async function handleNeighbors(itemId: string, limit: number) { const startTime = Date.now(); // Try Gorse neighbors if (GORSE_URL && GORSE_URL !== 'http://localhost:8088') { const neighborIds = await getGorseItemNeighbors(itemId, limit); if (neighborIds.length > 0) { const hydrated = await hydrateProducts(neighborIds); if (hydrated.length > 0) { return NextResponse.json({ success: true, source: 'gorse', products: hydrated, latencyMs: Date.now() - startTime, }); } } } // Fallback: Chroma semantic similarity (same model that powers smart-search) // Reuse the query-time embedding flow — embed the item's text, query Chroma for neighbors. // We do this by calling the smart-search internals indirectly: fetch product, embed, query. // For simplicity here, we just return empty if no Gorse; the smart-search endpoint already // does Chroma similarity for ad-hoc queries. return NextResponse.json({ success: true, source: 'empty', products: [], latencyMs: Date.now() - startTime, }); } /** * Feedback Sync — non-blocking, fires to Gorse in background */ async function handleFeedback(userId: string, feedback: any) { if (!feedback || !feedback.itemId || !feedback.type) { return NextResponse.json({ success: false, error: 'Missing feedback fields' }, { status: 400 }); } sendGorseFeedback(userId, feedback.itemId, feedback.type, feedback.score); return NextResponse.json({ success: true, message: 'Feedback received', }); } /** * Incremental Chroma sync — embed a product on create/update. * Called by /api/seller-products when a seller creates/edits a product. * Non-blocking from the user's perspective — the seller's product is saved * to Supabase first, then this is fired in the background. */ async function handleProductEmbed(product: any) { if (!product || !product.id) { return NextResponse.json({ success: false, error: 'Missing product.id' }, { status: 400 }); } // Fire and forget — we don't block the seller's request on Chroma/NVIDIA upsertProductToChroma(product.id, product).then((ok) => { if (!ok) console.warn(`[recommend] product_embed failed for ${product.id}`); }); return NextResponse.json({ success: true, message: 'Embedding queued', productId: product.id, }); } /** * Incremental Chroma sync — delete a product's embedding on product delete. */ async function handleProductDelete(productId: string | number) { if (!productId) { return NextResponse.json({ success: false, error: 'Missing productId' }, { status: 400 }); } deleteProductFromChroma(productId).then((ok) => { if (!ok) console.warn(`[recommend] product_delete failed for ${productId}`); }); return NextResponse.json({ success: true, message: 'Delete queued', productId, }); } /** * Hydrate product IDs with full product data from Supabase. */ async function hydrateProducts(productIds: string[]): Promise { if (!productIds.length) return []; const sqlHeaders: Record = { 'Authorization': `Bearer ${SUPABASE_TOKEN}`, 'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0', }; try { const ids = productIds.map((id) => `'${String(id).replace(/'/g, "''")}'`).join(','); const resp = await fetch(`https://api.supabase.com/v1/projects/${PROJECT}/database/query`, { method: 'POST', headers: sqlHeaders, body: JSON.stringify({ query: `SELECT id, name, price, image_url, category, seller_id, units_sold, description, created_at FROM products WHERE id IN (${ids});`, }), }); const data = await resp.json(); if (!Array.isArray(data)) return []; // Sort by the order they were returned (most relevant first) const productMap = new Map(data.map((p: any) => [String(p.id), p])); return productIds .map((id) => productMap.get(id)) .filter(Boolean); } catch (err) { console.error('[recommend] hydrateProducts failed:', err); return []; } } /** * Hydrate video IDs with full video data from Supabase. */ async function hydrateVideos(videoIds: string[]): Promise { if (!videoIds.length) return []; const sqlHeaders: Record = { 'Authorization': `Bearer ${SUPABASE_TOKEN}`, 'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0', }; try { const ids = videoIds.map((id) => `'${String(id).replace(/'/g, "''")}'`).join(','); const resp = await fetch(`https://api.supabase.com/v1/projects/${PROJECT}/database/query`, { method: 'POST', headers: sqlHeaders, body: JSON.stringify({ query: `SELECT v.id, v.video_url, v.caption, v.views_count, v.likes_count, v.created_at, v.product_id, p.name as product_name, p.price, p.image_url, s.business_name as seller_name, s.profile_image as seller_image FROM videos v LEFT JOIN products p ON v.product_id = p.id LEFT JOIN sellers s ON v.seller_id = s.id WHERE v.id IN (${ids});`, }), }); const data = await resp.json(); if (!Array.isArray(data)) return []; const videoMap = new Map(data.map((v: any) => [String(v.id), v])); return videoIds .map((id) => videoMap.get(id)) .filter(Boolean); } catch (err) { console.error('[recommend] hydrateVideos failed:', err); return []; } }