File size: 25,595 Bytes
bea55e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
/**
 * 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 [];
  }
}