AdarshDRC commited on
Commit
e8a2d3b
·
verified ·
1 Parent(s): cafd4ec

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +239 -268
main.py CHANGED
@@ -38,6 +38,9 @@ def _get_pinecone(api_key: str) -> Pinecone:
38
  _pinecone_pool.move_to_end(api_key)
39
  return _pinecone_pool[api_key]
40
 
 
 
 
41
  def _cld_upload(tmp_path, folder, creds):
42
  return cloudinary.uploader.upload(
43
  tmp_path, folder=folder,
@@ -54,42 +57,11 @@ def _cld_root_folders(creds):
54
  api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
55
  )
56
 
57
- def _cld_resources_by_folder(folder, creds, max_results=100, next_cursor=None):
58
- kwargs = dict(
59
- type="upload", prefix=f"{folder}/", max_results=max_results,
60
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
61
- )
62
- if next_cursor:
63
- kwargs["next_cursor"] = next_cursor
64
- return cloudinary.api.resources(**kwargs)
65
-
66
- def _cld_delete_resource(public_id, creds):
67
- return cloudinary.uploader.destroy(
68
- public_id,
69
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
70
- )
71
-
72
- def _cld_delete_by_prefix(prefix, creds):
73
- """Delete all resources under a folder prefix."""
74
- return cloudinary.api.delete_resources_by_prefix(
75
- prefix,
76
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
77
- )
78
-
79
- def _cld_delete_folder(folder, creds):
80
- """Delete a Cloudinary folder (must be empty first)."""
81
- try:
82
- return cloudinary.api.delete_folder(
83
- folder,
84
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
85
- )
86
- except Exception:
87
- pass # Folder may not exist — ignore
88
-
89
  @asynccontextmanager
90
  async def lifespan(app: FastAPI):
91
  global ai, _inference_sem
92
  from src.models import AIModelManager
 
93
  print("⏳ Loading AI models …")
94
  loop = asyncio.get_event_loop()
95
  ai = await loop.run_in_executor(None, AIModelManager)
@@ -115,30 +87,6 @@ def get_cloudinary_creds(env_url: str) -> dict:
115
  parsed = urlparse(env_url)
116
  return {"api_key": parsed.username, "api_secret": parsed.password, "cloud_name": parsed.hostname}
117
 
118
- def url_to_public_id(image_url: str, cloud_name: str) -> str:
119
- """
120
- Extract Cloudinary public_id from a secure_url.
121
- e.g. https://res.cloudinary.com/mycloud/image/upload/v123456/folder/filename.jpg
122
- → folder/filename
123
- """
124
- try:
125
- path = urlparse(image_url).path # /mycloud/image/upload/v123456/folder/filename.jpg
126
- # Remove leading /cloudname/image/upload/ and version segment
127
- parts = path.strip('/').split('/')
128
- # Find 'upload' and skip it + version
129
- upload_idx = next(i for i, p in enumerate(parts) if p == 'upload')
130
- after_upload = parts[upload_idx + 1:]
131
- # Skip version segment (starts with 'v' followed by digits)
132
- if after_upload and re.match(r'^v\d+$', after_upload[0]):
133
- after_upload = after_upload[1:]
134
- # Remove file extension
135
- file_with_ext = after_upload[-1]
136
- after_upload[-1] = re.sub(r'\.[^.]+$', '', file_with_ext)
137
- return '/'.join(after_upload)
138
- except Exception:
139
- return ""
140
-
141
-
142
  # ══════════════════════════════════════════════════════════════════
143
  # 1. VERIFY KEYS & AUTO-BUILD INDEXES
144
  # ══════════════════════════════════════════════════════════════════
@@ -169,38 +117,35 @@ async def verify_keys(pinecone_key: str = Form(""), cloudinary_url: str = Form("
169
 
170
 
171
  # ══════════════════════════════════════════════════════════════════
172
- # 2. UPLOAD
173
  # ══════════════════════════════════════════════════════════════════
174
  @app.post("/api/upload")
175
- async def upload_new_images(
176
- files: List[UploadFile] = File(...),
177
- folder_name: str = Form(...),
178
- detect_faces: bool = Form(True),
179
- user_pinecone_key: str = Form(""),
180
- user_cloudinary_url: str = Form("")
181
- ):
182
- actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
183
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
 
184
  if not actual_pc_key or not actual_cld_url:
185
- raise HTTPException(400, "API Keys are missing.")
186
 
187
  folder = standardize_category_name(folder_name)
188
- creds = get_cloudinary_creds(actual_cld_url)
 
 
189
  if not creds.get("cloud_name"):
190
  raise HTTPException(400, "Invalid Cloudinary URL format.")
191
-
192
- pc = _get_pinecone(actual_pc_key)
193
- idx_obj = pc.Index(IDX_OBJECTS)
194
  idx_face = pc.Index(IDX_FACES)
195
- uploaded_urls = []
196
 
197
  for file in files:
198
  tmp_path = f"temp_uploads/{uuid.uuid4().hex}_{sanitize_filename(file.filename)}"
199
  try:
200
  with open(tmp_path, "wb") as buf:
201
  shutil.copyfileobj(file.file, buf)
202
-
203
- res = await asyncio.to_thread(_cld_upload, tmp_path, folder, creds)
204
  image_url = res["secure_url"]
205
  uploaded_urls.append(image_url)
206
 
@@ -210,45 +155,30 @@ async def upload_new_images(
210
  face_upserts, object_upserts = [], []
211
  for v in vectors:
212
  vec_list = v["vector"].tolist() if hasattr(v["vector"], "tolist") else v["vector"]
213
- # ── image_url added to metadata for reverse-lookup (delete by URL) ──
214
- record = {
215
- "id": str(uuid.uuid4()),
216
- "values": vec_list,
217
- "metadata": {
218
- "url": image_url,
219
- "image_url": image_url, # legacy compat
220
- "folder": folder,
221
- }
222
- }
223
  (face_upserts if v["type"] == "face" else object_upserts).append(record)
224
 
225
  upsert_tasks = []
226
- if face_upserts: upsert_tasks.append(asyncio.to_thread(idx_face.upsert, vectors=face_upserts))
227
- if object_upserts: upsert_tasks.append(asyncio.to_thread(idx_obj.upsert, vectors=object_upserts))
228
- if upsert_tasks: await asyncio.gather(*upsert_tasks)
229
-
230
  except Exception as e:
231
  print(f"❌ Upload error: {e}")
232
  raise HTTPException(500, f"Upload processing failed: {str(e)}")
233
  finally:
234
  if os.path.exists(tmp_path): os.remove(tmp_path)
235
-
236
  return {"message": "Done!", "urls": uploaded_urls}
237
 
238
 
239
  # ══════════════════════════════════════════════════════════════════
240
- # 3. SEARCH
241
  # ══════════════════════════════════════════════════════════════════
242
  @app.post("/api/search")
243
- async def search_database(
244
- file: UploadFile = File(...),
245
- detect_faces: bool = Form(True),
246
- user_pinecone_key: str = Form(""),
247
- user_cloudinary_url: str = Form("")
248
- ):
249
  actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
250
  if not actual_pc_key:
251
- raise HTTPException(400, "Pinecone Key is missing.")
252
 
253
  tmp_path = f"temp_uploads/query_{uuid.uuid4().hex}_{sanitize_filename(file.filename)}"
254
  try:
@@ -258,49 +188,64 @@ async def search_database(
258
  async with _inference_sem:
259
  vectors = await ai.process_image_async(tmp_path, is_query=True, detect_faces=detect_faces)
260
 
261
- pc = _get_pinecone(actual_pc_key)
262
- idx_obj = pc.Index(IDX_OBJECTS)
263
  idx_face = pc.Index(IDX_FACES)
264
 
265
  async def _query_one(vec_dict: dict):
266
- vec_list = vec_dict["vector"].tolist() if hasattr(vec_dict["vector"], "tolist") else vec_dict["vector"]
267
  target_idx = idx_face if vec_dict["type"] == "face" else idx_obj
 
268
  try:
269
  res = await asyncio.to_thread(target_idx.query, vector=vec_list, top_k=10, include_metadata=True)
270
  except Exception as e:
271
  if "404" in str(e):
272
- raise HTTPException(404, "Pinecone Index not found. Go to Settings Configuration Verify Keys.")
273
  raise e
 
274
  out = []
275
  for match in res.get("matches", []):
276
- score = match["score"]
277
  is_face = vec_dict["type"] == "face"
 
 
 
 
278
  if is_face:
279
- RAW_THRESHOLD = 0.35
280
- if score < RAW_THRESHOLD: continue
 
281
  ui_score = 0.75 + ((score - RAW_THRESHOLD) / (1.0 - RAW_THRESHOLD)) * 0.24
282
  ui_score = min(0.99, ui_score)
283
  else:
284
- if score < 0.45: continue
 
 
 
 
 
285
  ui_score = score
 
286
  caption = "👤 Verified Identity" if is_face else match["metadata"].get("folder", "🎯 Object Match")
287
  out.append({
288
- "url": match["metadata"].get("url") or match["metadata"].get("image_url", ""),
289
  "score": round(ui_score, 4),
290
  "caption": caption,
291
  })
292
  return out
293
 
294
- nested = await asyncio.gather(*[_query_one(v) for v in vectors])
295
  all_results = [r for sub in nested for r in sub]
 
296
  seen = {}
297
  for r in all_results:
298
  url = r["url"]
299
  if url not in seen or r["score"] > seen[url]["score"]:
300
  seen[url] = r
301
- return {"results": sorted(seen.values(), key=lambda x: x["score"], reverse=True)[:10]}
302
 
303
- except HTTPException: raise
 
 
304
  except Exception as e:
305
  print(f"❌ Search error: {e}")
306
  raise HTTPException(500, str(e))
@@ -316,10 +261,12 @@ async def get_categories(user_cloudinary_url: str = Form("")):
316
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
317
  if not actual_cld_url:
318
  return {"categories": []}
 
319
  try:
320
  creds = get_cloudinary_creds(actual_cld_url)
321
  if not creds.get("cloud_name"):
322
  return {"categories": []}
 
323
  result = await asyncio.to_thread(_cld_root_folders, creds)
324
  return {"categories": [f["name"] for f in result.get("folders", [])]}
325
  except Exception as e:
@@ -327,154 +274,211 @@ async def get_categories(user_cloudinary_url: str = Form("")):
327
  return {"categories": []}
328
 
329
 
 
 
 
330
  # ══════════════════════════════════════════════════════════════════
331
- # 5. CLOUDINARY FOLDER IMAGES (File Explorer)
332
  # ══════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
333
  @app.post("/api/cloudinary/folder-images")
334
- async def get_folder_images(
335
  user_cloudinary_url: str = Form(""),
336
- folder_name: str = Form(...)
337
  ):
338
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
339
- if not actual_cld_url:
340
- raise HTTPException(400, "Cloudinary URL is required.")
341
  creds = get_cloudinary_creds(actual_cld_url)
342
  if not creds.get("cloud_name"):
343
  raise HTTPException(400, "Invalid Cloudinary URL.")
344
- try:
345
- images = []
346
- next_cursor = None
347
- while True:
348
- result = await asyncio.to_thread(
349
- _cld_resources_by_folder, folder_name, creds, 100, next_cursor
350
- )
351
- for r in result.get("resources", []):
352
- images.append({
353
- "url": r["secure_url"],
354
- "public_id": r["public_id"],
355
- "format": r.get("format", ""),
356
- "width": r.get("width", 0),
357
- "height": r.get("height", 0),
358
- "bytes": r.get("bytes", 0),
359
- })
360
- next_cursor = result.get("next_cursor")
361
- if not next_cursor:
362
- break
363
- return {"images": images}
364
- except Exception as e:
365
- print(f"Folder images error: {e}")
366
- raise HTTPException(500, str(e))
367
 
368
 
369
  # ══════════════════════════════════════════════════════════════════
370
- # 6. DELETE SINGLE IMAGE (File Explorer)
371
- # Deletes from Cloudinary + removes matching vectors from Pinecone
372
  # ══════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  @app.post("/api/delete-image")
374
  async def delete_image(
375
- image_url: str = Form(...),
376
  user_cloudinary_url: str = Form(""),
377
- user_pinecone_key: str = Form("")
 
378
  ):
 
379
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
380
- actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
381
- if not actual_cld_url or not actual_pc_key:
382
- raise HTTPException(400, "Both Cloudinary URL and Pinecone Key are required.")
383
-
384
  creds = get_cloudinary_creds(actual_cld_url)
385
  if not creds.get("cloud_name"):
386
  raise HTTPException(400, "Invalid Cloudinary URL.")
387
 
388
- errors = []
 
 
389
 
390
- # ── 1. Delete from Cloudinary ────────────────────────────────
391
- public_id = url_to_public_id(image_url, creds["cloud_name"])
392
- if public_id:
 
 
393
  try:
394
- await asyncio.to_thread(_cld_delete_resource, public_id, creds)
 
 
 
395
  except Exception as e:
396
- errors.append(f"Cloudinary deletion failed: {e}")
397
- else:
398
- errors.append("Could not extract public_id from URL — Cloudinary deletion skipped.")
399
 
400
- # ── 2. Delete matching vectors from Pinecone by metadata filter ─
401
- # Works only for vectors uploaded AFTER the image_url metadata fix.
402
- # Old vectors (without image_url metadata) will not be found — that's expected.
 
 
 
 
 
 
 
 
403
  try:
404
- pc = _get_pinecone(actual_pc_key)
405
- idx_obj = pc.Index(IDX_OBJECTS)
406
- idx_face = pc.Index(IDX_FACES)
 
 
 
407
 
408
- for idx in [idx_obj, idx_face]:
409
- try:
410
- # Query for vectors with this exact image_url in metadata
411
- matches = await asyncio.to_thread(
412
- idx.query,
413
- vector=[0.0] * (1536 if idx == idx_obj else 512),
414
- top_k=50,
415
- include_metadata=True,
416
- filter={"image_url": {"$eq": image_url}}
417
- )
418
- ids_to_delete = [m["id"] for m in matches.get("matches", []) if m.get("metadata", {}).get("image_url") == image_url or m.get("metadata", {}).get("url") == image_url]
419
- if ids_to_delete:
420
- await asyncio.to_thread(idx.delete, ids=ids_to_delete)
421
- except Exception as e:
422
- # Pinecone filter queries may not be supported on all plan types — log but don't fail
423
- print(f"Pinecone vector delete attempt failed (non-critical): {e}")
424
 
425
- except Exception as e:
426
- errors.append(f"Pinecone deletion failed: {e}")
 
 
 
 
 
 
 
427
 
428
- if len(errors) == 2:
429
- raise HTTPException(500, " | ".join(errors))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
 
431
- return {"message": "Image deleted.", "warnings": errors}
432
 
433
 
434
  # ════════════════���═════════════════════════════════════════════════
435
- # 7. RESET DATABASE
436
- # Deletes ALL Cloudinary images + wipes + recreates Pinecone indexes
437
- # Only operates on the user's OWN keys (never defaults)
438
  # ══════════════════════════════════════════════════════════════════
 
 
 
 
 
 
439
  @app.post("/api/reset-database")
440
  async def reset_database(
441
- user_pinecone_key: str = Form(...),
442
- user_cloudinary_url: str = Form(...)
443
  ):
444
- if not user_pinecone_key or not user_cloudinary_url:
445
- raise HTTPException(400, "Your own Pinecone Key and Cloudinary URL are required. This endpoint only operates on personal databases.")
446
-
447
- # Guard: refuse to operate on server default keys
448
- default_pc = os.getenv("DEFAULT_PINECONE_KEY", "")
449
- default_cld = os.getenv("DEFAULT_CLOUDINARY_URL", "")
450
- if user_pinecone_key == default_pc or user_cloudinary_url == default_cld:
451
- raise HTTPException(403, "Cannot reset the shared default database. This operation is only permitted on your personal database.")
452
 
453
  creds = get_cloudinary_creds(user_cloudinary_url)
454
  if not creds.get("cloud_name"):
455
  raise HTTPException(400, "Invalid Cloudinary URL.")
456
 
457
- errors = []
458
-
459
- # ── 1. Wipe ALL Cloudinary resources ───────────────────────
460
  try:
461
- # Get all root folders and delete resources + folders
462
- folders_res = await asyncio.to_thread(_cld_root_folders, creds)
463
- folders = [f["name"] for f in folders_res.get("folders", [])]
464
- for folder in folders:
465
- try:
466
- await asyncio.to_thread(_cld_delete_by_prefix, f"{folder}/", creds)
467
- await asyncio.to_thread(_cld_delete_folder, folder, creds)
468
- except Exception as e:
469
- errors.append(f"Cloudinary folder '{folder}' error: {e}")
470
  except Exception as e:
471
- errors.append(f"Cloudinary wipe error: {e}")
472
 
473
- # ── 2. Delete and recreate Pinecone indexes ─────────────────
474
  try:
475
- pc = _get_pinecone(user_pinecone_key)
476
  existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
477
-
478
  delete_tasks = []
479
  if IDX_OBJECTS in existing:
480
  delete_tasks.append(asyncio.to_thread(pc.delete_index, IDX_OBJECTS))
@@ -482,84 +486,51 @@ async def reset_database(
482
  delete_tasks.append(asyncio.to_thread(pc.delete_index, IDX_FACES))
483
  if delete_tasks:
484
  await asyncio.gather(*delete_tasks)
485
-
486
- # Wait a moment for deletion to propagate
487
- await asyncio.sleep(3)
488
-
489
- create_tasks = [
490
  asyncio.to_thread(pc.create_index, name=IDX_OBJECTS, dimension=1536, metric="cosine", spec=ServerlessSpec(cloud="aws", region="us-east-1")),
491
  asyncio.to_thread(pc.create_index, name=IDX_FACES, dimension=512, metric="cosine", spec=ServerlessSpec(cloud="aws", region="us-east-1")),
492
- ]
493
- await asyncio.gather(*create_tasks)
494
-
495
- # Evict this key from the pool so we get fresh index handles
496
- if user_pinecone_key in _pinecone_pool:
497
- del _pinecone_pool[user_pinecone_key]
498
-
499
  except Exception as e:
500
- errors.append(f"Pinecone reset error: {e}")
501
 
502
- if errors:
503
- return {"message": "Reset completed with some errors.", "warnings": errors}
504
- return {"message": "Database wiped and recreated successfully."}
505
 
506
 
507
  # ══════════════════════════════════════════════════════════════════
508
- # 8. DELETE ACCOUNT
509
- # Full wipe: Cloudinary + Pinecone + Supabase settings row
510
- # Note: Supabase auth user deletion must be done client-side
511
- # via supabase.auth.admin or the user's own session.
512
  # ══════════════════════════════════════════════════════════════════
513
  @app.post("/api/delete-account")
514
  async def delete_account(
515
- user_pinecone_key: str = Form(...),
516
- user_cloudinary_url: str = Form(...),
517
- user_id: str = Form(...)
518
  ):
519
- if not user_pinecone_key or not user_cloudinary_url:
520
- raise HTTPException(400, "Your own API keys are required.")
521
-
522
- # Guard against default keys
523
- default_pc = os.getenv("DEFAULT_PINECONE_KEY", "")
524
- default_cld = os.getenv("DEFAULT_CLOUDINARY_URL", "")
525
- if user_pinecone_key == default_pc or user_cloudinary_url == default_cld:
526
- raise HTTPException(403, "Cannot delete using shared default keys.")
527
 
528
- # Reuse reset logic for data wipe
529
  creds = get_cloudinary_creds(user_cloudinary_url)
530
-
531
- # Wipe Cloudinary
532
  try:
533
- folders_res = await asyncio.to_thread(_cld_root_folders, creds)
534
- for f in folders_res.get("folders", []):
535
- await asyncio.to_thread(_cld_delete_by_prefix, f"{f['name']}/", creds)
536
- await asyncio.to_thread(_cld_delete_folder, f["name"], creds)
 
537
  except Exception as e:
538
- print(f"Account delete Cloudinary error: {e}")
539
 
540
- # Wipe + delete Pinecone indexes
541
  try:
542
  pc = _get_pinecone(user_pinecone_key)
543
  existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
544
- tasks = []
545
- if IDX_OBJECTS in existing: tasks.append(asyncio.to_thread(pc.delete_index, IDX_OBJECTS))
546
- if IDX_FACES in existing: tasks.append(asyncio.to_thread(pc.delete_index, IDX_FACES))
547
- if tasks: await asyncio.gather(*tasks)
548
- if user_pinecone_key in _pinecone_pool: del _pinecone_pool[user_pinecone_key]
 
 
549
  except Exception as e:
550
- print(f"Account delete Pinecone error: {e}")
551
-
552
- # Note: Supabase user deletion & settings row deletion is handled
553
- # client-side via supabase.auth.signOut() after this endpoint returns.
554
- # The Supabase ON DELETE CASCADE on user_settings + user_folders
555
- # handles row cleanup automatically when the auth user is deleted.
556
-
557
- return {"message": "Account data wiped. Please confirm deletion in your Supabase dashboard if needed."}
558
-
559
 
560
- # ══════════════════════════════════════════════════════════════════
561
- # 9. HEALTH CHECK
562
- # ══════════════════════════════════════════════════════════════════
563
- @app.get("/api/health")
564
- async def health():
565
- return {"status": "ok"}
 
38
  _pinecone_pool.move_to_end(api_key)
39
  return _pinecone_pool[api_key]
40
 
41
+ # ── Cloudinary: credentials injected per-call, NEVER globally configured.
42
+ # If cloudinary.config() is called once, it applies to the whole process —
43
+ # User A's credentials would bleed into User B's request under concurrency.
44
  def _cld_upload(tmp_path, folder, creds):
45
  return cloudinary.uploader.upload(
46
  tmp_path, folder=folder,
 
57
  api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
58
  )
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  @asynccontextmanager
61
  async def lifespan(app: FastAPI):
62
  global ai, _inference_sem
63
  from src.models import AIModelManager
64
+
65
  print("⏳ Loading AI models …")
66
  loop = asyncio.get_event_loop()
67
  ai = await loop.run_in_executor(None, AIModelManager)
 
87
  parsed = urlparse(env_url)
88
  return {"api_key": parsed.username, "api_secret": parsed.password, "cloud_name": parsed.hostname}
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  # ══════════════════════════════════════════════════════════════════
91
  # 1. VERIFY KEYS & AUTO-BUILD INDEXES
92
  # ══════════════════════════════════════════════════════════════════
 
117
 
118
 
119
  # ══════════════════════════════════════════════════════════════════
120
+ # 2. UPLOAD
121
  # ══════════════════════════════════════════════════════════════════
122
  @app.post("/api/upload")
123
+ async def upload_new_images(files: List[UploadFile] = File(...), folder_name: str = Form(...), detect_faces: bool = Form(True), user_pinecone_key: str = Form(""), user_cloudinary_url: str = Form("")):
124
+ # DEFENSIVE FIX: The 'or ""' ensures it never becomes None, preventing 500 crashes
125
+ actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
 
 
 
 
 
126
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
127
+
128
  if not actual_pc_key or not actual_cld_url:
129
+ raise HTTPException(400, "API Keys are missing. If you are a guest, the server is missing its DEFAULT_ secrets in Hugging Face.")
130
 
131
  folder = standardize_category_name(folder_name)
132
+ uploaded_urls = []
133
+
134
+ creds = get_cloudinary_creds(actual_cld_url)
135
  if not creds.get("cloud_name"):
136
  raise HTTPException(400, "Invalid Cloudinary URL format.")
137
+
138
+ pc = _get_pinecone(actual_pc_key)
139
+ idx_obj = pc.Index(IDX_OBJECTS)
140
  idx_face = pc.Index(IDX_FACES)
 
141
 
142
  for file in files:
143
  tmp_path = f"temp_uploads/{uuid.uuid4().hex}_{sanitize_filename(file.filename)}"
144
  try:
145
  with open(tmp_path, "wb") as buf:
146
  shutil.copyfileobj(file.file, buf)
147
+
148
+ res = await asyncio.to_thread(_cld_upload, tmp_path, folder, creds)
149
  image_url = res["secure_url"]
150
  uploaded_urls.append(image_url)
151
 
 
155
  face_upserts, object_upserts = [], []
156
  for v in vectors:
157
  vec_list = v["vector"].tolist() if hasattr(v["vector"], "tolist") else v["vector"]
158
+ record = {"id": str(uuid.uuid4()), "values": vec_list, "metadata": {"url": image_url, "folder": folder}}
 
 
 
 
 
 
 
 
 
159
  (face_upserts if v["type"] == "face" else object_upserts).append(record)
160
 
161
  upsert_tasks = []
162
+ if face_upserts: upsert_tasks.append(asyncio.to_thread(idx_face.upsert, vectors=face_upserts))
163
+ if object_upserts: upsert_tasks.append(asyncio.to_thread(idx_obj.upsert, vectors=object_upserts))
164
+ if upsert_tasks: await asyncio.gather(*upsert_tasks)
 
165
  except Exception as e:
166
  print(f"❌ Upload error: {e}")
167
  raise HTTPException(500, f"Upload processing failed: {str(e)}")
168
  finally:
169
  if os.path.exists(tmp_path): os.remove(tmp_path)
170
+
171
  return {"message": "Done!", "urls": uploaded_urls}
172
 
173
 
174
  # ══════════════════════════════════════════════════════════════════
175
+ # 3. SEARCH
176
  # ══════════════════════════════════════════════════════════════════
177
  @app.post("/api/search")
178
+ async def search_database(file: UploadFile = File(...), detect_faces: bool = Form(True), user_pinecone_key: str = Form(""), user_cloudinary_url: str = Form("")):
 
 
 
 
 
179
  actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
180
  if not actual_pc_key:
181
+ raise HTTPException(400, "Pinecone Key is missing. If you are a guest, the server is missing its DEFAULT_PINECONE_KEY in Hugging Face.")
182
 
183
  tmp_path = f"temp_uploads/query_{uuid.uuid4().hex}_{sanitize_filename(file.filename)}"
184
  try:
 
188
  async with _inference_sem:
189
  vectors = await ai.process_image_async(tmp_path, is_query=True, detect_faces=detect_faces)
190
 
191
+ pc = _get_pinecone(actual_pc_key)
192
+ idx_obj = pc.Index(IDX_OBJECTS)
193
  idx_face = pc.Index(IDX_FACES)
194
 
195
  async def _query_one(vec_dict: dict):
196
+ vec_list = vec_dict["vector"].tolist() if hasattr(vec_dict["vector"], "tolist") else vec_dict["vector"]
197
  target_idx = idx_face if vec_dict["type"] == "face" else idx_obj
198
+
199
  try:
200
  res = await asyncio.to_thread(target_idx.query, vector=vec_list, top_k=10, include_metadata=True)
201
  except Exception as e:
202
  if "404" in str(e):
203
+ raise HTTPException(404, f"Pinecone Index not found. Please log in and click 'Verify Keys' in Settings to build the indexes.")
204
  raise e
205
+
206
  out = []
207
  for match in res.get("matches", []):
208
+ score = match["score"]
209
  is_face = vec_dict["type"] == "face"
210
+
211
+ # ── Score filtering ──────────────────────────────────────
212
+ # Face lane: GhostFaceNet 512-D cosine similarity.
213
+ # Raw scores 0.3-0.5 = same person. Remap to 75-99% for UI.
214
  if is_face:
215
+ RAW_THRESHOLD = 0.35 # matches original cloud_db.py
216
+ if score < RAW_THRESHOLD:
217
+ continue
218
  ui_score = 0.75 + ((score - RAW_THRESHOLD) / (1.0 - RAW_THRESHOLD)) * 0.24
219
  ui_score = min(0.99, ui_score)
220
  else:
221
+ # Object lane: SigLIP+DINOv2 1536-D fused cosine similarity.
222
+ # Matches original cloud_db.py min_score=0.45 floor.
223
+ # Scores below 0.45 are pure noise — unrelated images.
224
+ MIN_OBJECT_SCORE = 0.45
225
+ if score < MIN_OBJECT_SCORE:
226
+ continue
227
  ui_score = score
228
+
229
  caption = "👤 Verified Identity" if is_face else match["metadata"].get("folder", "🎯 Object Match")
230
  out.append({
231
+ "url": match["metadata"].get("url") or match["metadata"].get("image_url", ""), # "image_url" = legacy key from cloud_db.py
232
  "score": round(ui_score, 4),
233
  "caption": caption,
234
  })
235
  return out
236
 
237
+ nested = await asyncio.gather(*[_query_one(v) for v in vectors])
238
  all_results = [r for sub in nested for r in sub]
239
+
240
  seen = {}
241
  for r in all_results:
242
  url = r["url"]
243
  if url not in seen or r["score"] > seen[url]["score"]:
244
  seen[url] = r
 
245
 
246
+ return {"results": sorted(seen.values(), key=lambda x: x["score"], reverse=True)[:10]}
247
+ except HTTPException:
248
+ raise
249
  except Exception as e:
250
  print(f"❌ Search error: {e}")
251
  raise HTTPException(500, str(e))
 
261
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
262
  if not actual_cld_url:
263
  return {"categories": []}
264
+
265
  try:
266
  creds = get_cloudinary_creds(actual_cld_url)
267
  if not creds.get("cloud_name"):
268
  return {"categories": []}
269
+
270
  result = await asyncio.to_thread(_cld_root_folders, creds)
271
  return {"categories": [f["name"] for f in result.get("folders", [])]}
272
  except Exception as e:
 
274
  return {"categories": []}
275
 
276
 
277
+ @app.get("/api/health")
278
+ async def health():
279
+ return {"status": "ok"}
280
  # ══════════════════════════════════════════════════════════════════
281
+ # 5. LIST FOLDER IMAGES
282
  # ══════════════════════════════════════════════════════════════════
283
+ def _cld_list_folder_images(folder: str, creds: dict, next_cursor: str = None):
284
+ kwargs = dict(
285
+ type="upload", prefix=f"{folder}/",
286
+ max_results=500,
287
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
288
+ )
289
+ if next_cursor:
290
+ kwargs["next_cursor"] = next_cursor
291
+ return cloudinary.api.resources(**kwargs)
292
+
293
  @app.post("/api/cloudinary/folder-images")
294
+ async def list_folder_images(
295
  user_cloudinary_url: str = Form(""),
296
+ folder_name: str = Form(...),
297
  ):
298
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
 
 
299
  creds = get_cloudinary_creds(actual_cld_url)
300
  if not creds.get("cloud_name"):
301
  raise HTTPException(400, "Invalid Cloudinary URL.")
302
+
303
+ images = []
304
+ next_cursor = None
305
+ while True:
306
+ result = await asyncio.to_thread(_cld_list_folder_images, folder_name, creds, next_cursor)
307
+ for r in result.get("resources", []):
308
+ images.append({"url": r["secure_url"], "public_id": r["public_id"]})
309
+ next_cursor = result.get("next_cursor")
310
+ if not next_cursor:
311
+ break
312
+
313
+ return {"images": images, "count": len(images)}
 
 
 
 
 
 
 
 
 
 
 
314
 
315
 
316
  # ══════════════════════════════════════════════════════════════════
317
+ # 6. DELETE SINGLE IMAGE
 
318
  # ══════════════════════════════════════════════════════════════════
319
+ def url_to_public_id(image_url: str, cloud_name: str) -> str:
320
+ """Extract Cloudinary public_id from secure_url."""
321
+ try:
322
+ path = urlparse(image_url).path
323
+ parts = path.split("/")
324
+ # Strip leading slash, cloud_name, delivery_type (image), access_mode (upload)
325
+ # Format: /cloud_name/image/upload/[v12345/]folder/filename.ext
326
+ upload_idx = parts.index("upload")
327
+ after_upload = parts[upload_idx + 1:]
328
+ # Strip version segment if present (starts with 'v' + digits)
329
+ if after_upload and after_upload[0].startswith("v") and after_upload[0][1:].isdigit():
330
+ after_upload = after_upload[1:]
331
+ public_id_with_ext = "/".join(after_upload)
332
+ # Strip file extension
333
+ public_id = public_id_with_ext.rsplit(".", 1)[0]
334
+ return public_id
335
+ except Exception:
336
+ return ""
337
+
338
+ def _cld_delete_resource(public_id: str, creds: dict):
339
+ return cloudinary.uploader.destroy(
340
+ public_id,
341
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
342
+ )
343
+
344
  @app.post("/api/delete-image")
345
  async def delete_image(
346
+ user_pinecone_key: str = Form(""),
347
  user_cloudinary_url: str = Form(""),
348
+ image_url: str = Form(""),
349
+ public_id: str = Form(""),
350
  ):
351
+ actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
352
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
 
 
 
 
353
  creds = get_cloudinary_creds(actual_cld_url)
354
  if not creds.get("cloud_name"):
355
  raise HTTPException(400, "Invalid Cloudinary URL.")
356
 
357
+ pid = public_id or url_to_public_id(image_url, creds["cloud_name"])
358
+ if not pid:
359
+ raise HTTPException(400, "Could not determine public_id.")
360
 
361
+ # Delete from Cloudinary
362
+ await asyncio.to_thread(_cld_delete_resource, pid, creds)
363
+
364
+ # Delete from Pinecone by metadata filter
365
+ if actual_pc_key and image_url:
366
  try:
367
+ pc = _get_pinecone(actual_pc_key)
368
+ for idx_name in [IDX_OBJECTS, IDX_FACES]:
369
+ idx = pc.Index(idx_name)
370
+ await asyncio.to_thread(idx.delete, filter={"url": {"$eq": image_url}})
371
  except Exception as e:
372
+ print(f"Pinecone delete warning: {e}")
373
+
374
+ return {"message": "Image deleted successfully."}
375
 
376
+
377
+ # ══════════════════════════════════════════════════════════════════
378
+ # 7. DELETE ENTIRE FOLDER
379
+ # ══════════════════════════════════════════════════════════════════
380
+ def _cld_delete_folder(folder: str, creds: dict):
381
+ return cloudinary.api.delete_resources_by_prefix(
382
+ f"{folder}/",
383
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
384
+ )
385
+
386
+ def _cld_remove_folder(folder: str, creds: dict):
387
  try:
388
+ return cloudinary.api.delete_folder(
389
+ folder,
390
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
391
+ )
392
+ except Exception:
393
+ pass # Folder may already be empty/gone
394
 
395
+ @app.post("/api/delete-folder")
396
+ async def delete_folder(
397
+ user_pinecone_key: str = Form(""),
398
+ user_cloudinary_url: str = Form(""),
399
+ folder_name: str = Form(...),
400
+ ):
401
+ actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
402
+ actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
403
+ creds = get_cloudinary_creds(actual_cld_url)
404
+ if not creds.get("cloud_name"):
405
+ raise HTTPException(400, "Invalid Cloudinary URL.")
 
 
 
 
 
406
 
407
+ # 1. List all images in folder first (for Pinecone cleanup)
408
+ all_images = []
409
+ next_cursor = None
410
+ while True:
411
+ result = await asyncio.to_thread(_cld_list_folder_images, folder_name, creds, next_cursor)
412
+ all_images.extend(result.get("resources", []))
413
+ next_cursor = result.get("next_cursor")
414
+ if not next_cursor:
415
+ break
416
 
417
+ # 2. Delete all images from Cloudinary
418
+ await asyncio.to_thread(_cld_delete_folder, folder_name, creds)
419
+
420
+ # 3. Remove the folder itself from Cloudinary
421
+ await asyncio.to_thread(_cld_remove_folder, folder_name, creds)
422
+
423
+ # 4. Delete Pinecone vectors for each image
424
+ if actual_pc_key:
425
+ try:
426
+ pc = _get_pinecone(actual_pc_key)
427
+ # Try bulk delete by folder metadata filter first
428
+ for idx_name in [IDX_OBJECTS, IDX_FACES]:
429
+ idx = pc.Index(idx_name)
430
+ try:
431
+ await asyncio.to_thread(idx.delete, filter={"folder": {"$eq": folder_name}})
432
+ except Exception:
433
+ # Fallback: delete by individual image URLs
434
+ for img in all_images:
435
+ try:
436
+ url = img.get("secure_url", "")
437
+ if url:
438
+ await asyncio.to_thread(idx.delete, filter={"url": {"$eq": url}})
439
+ except Exception:
440
+ pass
441
+ except Exception as e:
442
+ print(f"Pinecone folder delete warning: {e}")
443
 
444
+ return {"message": f"Folder '{folder_name}' and all its contents deleted.", "deleted_count": len(all_images)}
445
 
446
 
447
  # ════════════════���═════════════════════════════════════════════════
448
+ # 8. RESET DATABASE
 
 
449
  # ══════════════════════════════════════════════════════════════════
450
+ DEFAULT_PC_KEY = os.getenv("DEFAULT_PINECONE_KEY", "")
451
+ DEFAULT_CLD_URL = os.getenv("DEFAULT_CLOUDINARY_URL","")
452
+
453
+ def _is_default_key(key: str, default: str) -> bool:
454
+ return bool(default) and key.strip() == default.strip()
455
+
456
  @app.post("/api/reset-database")
457
  async def reset_database(
458
+ user_pinecone_key: str = Form(""),
459
+ user_cloudinary_url: str = Form(""),
460
  ):
461
+ if _is_default_key(user_pinecone_key, DEFAULT_PC_KEY) or _is_default_key(user_cloudinary_url, DEFAULT_CLD_URL):
462
+ raise HTTPException(403, "Reset is not allowed on the shared demo database.")
 
 
 
 
 
 
463
 
464
  creds = get_cloudinary_creds(user_cloudinary_url)
465
  if not creds.get("cloud_name"):
466
  raise HTTPException(400, "Invalid Cloudinary URL.")
467
 
468
+ # Wipe all Cloudinary resources
 
 
469
  try:
470
+ await asyncio.to_thread(
471
+ lambda: cloudinary.api.delete_all_resources(
472
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
473
+ )
474
+ )
 
 
 
 
475
  except Exception as e:
476
+ print(f"Cloudinary wipe warning: {e}")
477
 
478
+ # Delete and recreate Pinecone indexes
479
  try:
480
+ pc = _get_pinecone(user_pinecone_key)
481
  existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
 
482
  delete_tasks = []
483
  if IDX_OBJECTS in existing:
484
  delete_tasks.append(asyncio.to_thread(pc.delete_index, IDX_OBJECTS))
 
486
  delete_tasks.append(asyncio.to_thread(pc.delete_index, IDX_FACES))
487
  if delete_tasks:
488
  await asyncio.gather(*delete_tasks)
489
+ await asyncio.sleep(2)
490
+ await asyncio.gather(
 
 
 
491
  asyncio.to_thread(pc.create_index, name=IDX_OBJECTS, dimension=1536, metric="cosine", spec=ServerlessSpec(cloud="aws", region="us-east-1")),
492
  asyncio.to_thread(pc.create_index, name=IDX_FACES, dimension=512, metric="cosine", spec=ServerlessSpec(cloud="aws", region="us-east-1")),
493
+ )
 
 
 
 
 
 
494
  except Exception as e:
495
+ raise HTTPException(500, f"Pinecone reset error: {e}")
496
 
497
+ return {"message": "Database reset complete. All data wiped and indexes recreated."}
 
 
498
 
499
 
500
  # ══════════════════════════════════════════════════════════════════
501
+ # 9. DELETE ACCOUNT
 
 
 
502
  # ══════════════════════════════════════════════════════════════════
503
  @app.post("/api/delete-account")
504
  async def delete_account(
505
+ user_pinecone_key: str = Form(""),
506
+ user_cloudinary_url: str = Form(""),
507
+ user_id: str = Form(""),
508
  ):
509
+ if _is_default_key(user_pinecone_key, DEFAULT_PC_KEY) or _is_default_key(user_cloudinary_url, DEFAULT_CLD_URL):
510
+ raise HTTPException(403, "Account deletion is not allowed on the shared demo database.")
 
 
 
 
 
 
511
 
512
+ # Full wipe (same as reset)
513
  creds = get_cloudinary_creds(user_cloudinary_url)
 
 
514
  try:
515
+ await asyncio.to_thread(
516
+ lambda: cloudinary.api.delete_all_resources(
517
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
518
+ )
519
+ )
520
  except Exception as e:
521
+ print(f"Account delete Cloudinary warning: {e}")
522
 
 
523
  try:
524
  pc = _get_pinecone(user_pinecone_key)
525
  existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
526
+ delete_tasks = []
527
+ if IDX_OBJECTS in existing:
528
+ delete_tasks.append(asyncio.to_thread(pc.delete_index, IDX_OBJECTS))
529
+ if IDX_FACES in existing:
530
+ delete_tasks.append(asyncio.to_thread(pc.delete_index, IDX_FACES))
531
+ if delete_tasks:
532
+ await asyncio.gather(*delete_tasks)
533
  except Exception as e:
534
+ print(f"Account delete Pinecone warning: {e}")
 
 
 
 
 
 
 
 
535
 
536
+ return {"message": "Account data deleted. Sign out initiated."}