Spaces:
Sleeping
Sleeping
| 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<any[]> { | |
| if (!productIds.length) return []; | |
| const sqlHeaders: Record<string, string> = { | |
| '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<any[]> { | |
| if (!videoIds.length) return []; | |
| const sqlHeaders: Record<string, string> = { | |
| '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 []; | |
| } | |
| } | |