AdarshDRC commited on
Commit
6fc43b2
·
verified ·
1 Parent(s): 6e68a97

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +473 -322
main.py CHANGED
@@ -3,31 +3,113 @@ import os
3
  import shutil
4
  import uuid
5
  import re
 
 
 
 
6
  import inflect
 
7
  from urllib.parse import urlparse
8
  from typing import List
9
  from contextlib import asynccontextmanager
10
  from collections import OrderedDict
11
 
12
- from fastapi import FastAPI, UploadFile, File, Form, HTTPException
13
  from fastapi.middleware.cors import CORSMiddleware
14
  import cloudinary
15
  import cloudinary.uploader
16
  import cloudinary.api
17
  from pinecone import Pinecone, ServerlessSpec
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  # ── Deferred imports ─────────────────────────────────────────────
20
  ai = None
21
- p = inflect.engine()
22
 
23
  MAX_CONCURRENT_INFERENCES = int(os.getenv("MAX_CONCURRENT_INFERENCES", "6"))
24
  _inference_sem: asyncio.Semaphore
25
-
26
  _pinecone_pool = OrderedDict()
27
- _POOL_MAX = 64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
- IDX_FACES = "enterprise-faces"
30
- IDX_OBJECTS = "enterprise-objects"
 
31
 
32
  def _get_pinecone(api_key: str) -> Pinecone:
33
  if api_key not in _pinecone_pool:
@@ -37,120 +119,199 @@ def _get_pinecone(api_key: str) -> Pinecone:
37
  _pinecone_pool.move_to_end(api_key)
38
  return _pinecone_pool[api_key]
39
 
40
- # ── Cloudinary: credentials injected per-call, NEVER globally configured.
41
- # If cloudinary.config() is called once, it applies to the whole process —
42
- # User A's credentials would bleed into User B's request under concurrency.
43
  def _cld_upload(tmp_path, folder, creds):
44
- return cloudinary.uploader.upload(
45
- tmp_path, folder=folder,
46
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
47
- )
48
 
49
  def _cld_ping(creds):
50
  return cloudinary.api.ping(
51
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
52
- )
53
 
54
  def _cld_root_folders(creds):
55
  return cloudinary.api.root_folders(
56
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
57
- )
 
 
 
 
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  @asynccontextmanager
60
  async def lifespan(app: FastAPI):
61
  global ai, _inference_sem
62
  from src.models import AIModelManager
63
-
64
- print("⏳ Loading AI models …")
65
  loop = asyncio.get_event_loop()
66
- ai = await loop.run_in_executor(None, AIModelManager)
67
  _inference_sem = asyncio.Semaphore(MAX_CONCURRENT_INFERENCES)
68
- print(" Ready!")
69
  yield
 
70
 
71
  app = FastAPI(lifespan=lifespan)
72
- app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
 
 
73
  os.makedirs("temp_uploads", exist_ok=True)
74
 
75
- def standardize_category_name(name: str) -> str:
76
- clean = re.sub(r'\s+', '_', name.strip().lower())
77
- clean = re.sub(r'[^\w]', '', clean)
78
- return p.singular_noun(clean) or clean
79
 
80
- def sanitize_filename(filename: str) -> str:
81
- return re.sub(r'[^\w.\-]', '', re.sub(r'\s+', '_', filename))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
- def get_cloudinary_creds(env_url: str) -> dict:
84
- if not env_url:
85
- return {}
86
- parsed = urlparse(env_url)
87
- return {"api_key": parsed.username, "api_secret": parsed.password, "cloud_name": parsed.hostname}
88
 
89
- # ══════════════════════════════════════════════════════════════════
90
- # 1. VERIFY KEYS & AUTO-BUILD INDEXES
91
- # ══════════════════════════════════════════════════════════════════
92
  @app.post("/api/verify-keys")
93
- async def verify_keys(pinecone_key: str = Form(""), cloudinary_url: str = Form("")):
 
 
 
 
 
 
 
 
 
 
 
 
94
  if cloudinary_url:
95
  try:
96
  creds_v = get_cloudinary_creds(cloudinary_url)
97
  if not creds_v.get("cloud_name"): raise ValueError("bad url")
98
  await asyncio.to_thread(_cld_ping, creds_v)
99
  except HTTPException: raise
100
- except Exception:
 
 
 
101
  raise HTTPException(400, "Invalid Cloudinary Environment URL.")
 
 
102
  if pinecone_key:
103
  try:
104
- pc = _get_pinecone(pinecone_key)
105
  existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
106
- tasks = []
107
  if IDX_OBJECTS not in existing:
108
- tasks.append(asyncio.to_thread(pc.create_index, name=IDX_OBJECTS, dimension=1536, metric="cosine", spec=ServerlessSpec(cloud="aws", region="us-east-1")))
 
 
109
  if IDX_FACES not in existing:
110
- tasks.append(asyncio.to_thread(pc.create_index, name=IDX_FACES, dimension=512, metric="cosine", spec=ServerlessSpec(cloud="aws", region="us-east-1")))
111
- if tasks:
112
- await asyncio.gather(*tasks)
 
113
  except Exception as e:
114
- err_str = str(e).lower()
115
- if "401" in err_str or "unauthorized" in err_str or "invalid api key" in err_str:
116
- raise HTTPException(400, "Invalid Pinecone API Key. Please check your key and try again.")
117
- elif "403" in err_str or "forbidden" in err_str:
118
- raise HTTPException(400, "Pinecone API Key does not have sufficient permissions.")
119
- else:
120
- raise HTTPException(400, f"Pinecone connection failed. Please check your API key.")
 
 
 
 
 
 
121
  return {"message": "Keys verified and indexes ready!"}
122
 
123
 
124
- # ══════════════════════════════════════════════════════════════════
125
- # 2. UPLOAD
126
- # ══════════════════════════════════════════════════════════════════
127
  @app.post("/api/upload")
128
- 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("")):
129
- # DEFENSIVE FIX: The 'or ""' ensures it never becomes None, preventing 500 crashes
130
- actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
 
 
 
 
 
 
 
 
 
131
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
132
-
 
 
 
 
 
 
 
133
  if not actual_pc_key or not actual_cld_url:
 
134
  raise HTTPException(400, "API Keys are missing. If you are a guest, the server is missing its DEFAULT_ secrets in Hugging Face.")
135
 
136
  folder = standardize_category_name(folder_name)
137
- uploaded_urls = []
138
-
139
- creds = get_cloudinary_creds(actual_cld_url)
140
  if not creds.get("cloud_name"):
 
141
  raise HTTPException(400, "Invalid Cloudinary URL format.")
142
-
143
- pc = _get_pinecone(actual_pc_key)
144
- idx_obj = pc.Index(IDX_OBJECTS)
145
- idx_face = pc.Index(IDX_FACES)
 
 
 
146
 
147
  for file in files:
148
- tmp_path = f"temp_uploads/{uuid.uuid4().hex}_{sanitize_filename(file.filename)}"
 
149
  try:
150
  with open(tmp_path, "wb") as buf:
151
  shutil.copyfileobj(file.file, buf)
152
-
153
- res = await asyncio.to_thread(_cld_upload, tmp_path, folder, creds)
154
  image_url = res["secure_url"]
155
  uploaded_urls.append(image_url)
156
 
@@ -160,37 +321,72 @@ async def upload_new_images(files: List[UploadFile] = File(...), folder_name: st
160
  face_upserts, object_upserts = [], []
161
  for v in vectors:
162
  vec_list = v["vector"].tolist() if hasattr(v["vector"], "tolist") else v["vector"]
163
- record = {"id": str(uuid.uuid4()), "values": vec_list, "metadata": {"url": image_url, "folder": folder}}
 
164
  (face_upserts if v["type"] == "face" else object_upserts).append(record)
165
 
 
 
 
166
  upsert_tasks = []
167
- if face_upserts: upsert_tasks.append(asyncio.to_thread(idx_face.upsert, vectors=face_upserts))
168
- if object_upserts: upsert_tasks.append(asyncio.to_thread(idx_obj.upsert, vectors=object_upserts))
169
- if upsert_tasks: await asyncio.gather(*upsert_tasks)
170
- except HTTPException:
171
- raise
 
 
 
 
 
 
172
  except Exception as e:
173
- err_str = str(e)
174
- print(f"❌ Upload error: {err_str}")
175
- if "not found" in err_str.lower() or "404" in err_str or "does not exist" in err_str.lower():
 
 
 
 
176
  raise HTTPException(404, "Pinecone index not found. Please go to Settings and click 'Verify & Save' to recreate your indexes.")
177
- elif "401" in err_str or "unauthorized" in err_str.lower():
178
- raise HTTPException(401, "Invalid Pinecone API Key. Please check your key in Settings.")
179
- raise HTTPException(500, f"Upload processing failed: {err_str}")
180
  finally:
181
  if os.path.exists(tmp_path): os.remove(tmp_path)
182
-
 
 
 
 
 
 
 
183
  return {"message": "Done!", "urls": uploaded_urls}
184
 
185
 
186
- # ══════════════════════════════════════════════════════════════════
187
- # 3. SEARCH
188
- # ══════════════════════════════════════════════════════════════════
189
  @app.post("/api/search")
190
- async def search_database(file: UploadFile = File(...), detect_faces: bool = Form(True), user_pinecone_key: str = Form(""), user_cloudinary_url: str = Form("")):
 
 
 
 
 
 
 
 
 
191
  actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
 
 
 
 
 
 
192
  if not actual_pc_key:
193
- raise HTTPException(400, "Pinecone Key is missing. If you are a guest, the server is missing its DEFAULT_PINECONE_KEY in Hugging Face.")
 
194
 
195
  tmp_path = f"temp_uploads/query_{uuid.uuid4().hex}_{sanitize_filename(file.filename)}"
196
  try:
@@ -200,388 +396,343 @@ async def search_database(file: UploadFile = File(...), detect_faces: bool = For
200
  async with _inference_sem:
201
  vectors = await ai.process_image_async(tmp_path, is_query=True, detect_faces=detect_faces)
202
 
203
- pc = _get_pinecone(actual_pc_key)
204
- idx_obj = pc.Index(IDX_OBJECTS)
 
 
 
 
 
 
205
  idx_face = pc.Index(IDX_FACES)
206
 
207
  async def _query_one(vec_dict: dict):
208
- vec_list = vec_dict["vector"].tolist() if hasattr(vec_dict["vector"], "tolist") else vec_dict["vector"]
209
  target_idx = idx_face if vec_dict["type"] == "face" else idx_obj
210
-
211
  try:
212
  res = await asyncio.to_thread(target_idx.query, vector=vec_list, top_k=10, include_metadata=True)
213
  except Exception as e:
214
  if "404" in str(e):
215
- raise HTTPException(404, f"Pinecone Index not found. Please log in and click 'Verify Keys' in Settings to build the indexes.")
216
  raise e
217
-
218
  out = []
219
  for match in res.get("matches", []):
220
- score = match["score"]
221
  is_face = vec_dict["type"] == "face"
222
-
223
- # ── Score filtering ──────────────────────────────────────
224
- # Face lane: GhostFaceNet 512-D cosine similarity.
225
- # Raw scores 0.3-0.5 = same person. Remap to 75-99% for UI.
226
  if is_face:
227
- RAW_THRESHOLD = 0.35 # matches original cloud_db.py
228
- if score < RAW_THRESHOLD:
229
- continue
230
- ui_score = 0.75 + ((score - RAW_THRESHOLD) / (1.0 - RAW_THRESHOLD)) * 0.24
231
- ui_score = min(0.99, ui_score)
232
  else:
233
- # Object lane: SigLIP+DINOv2 1536-D fused cosine similarity.
234
- # Matches original cloud_db.py min_score=0.45 floor.
235
- # Scores below 0.45 are pure noise — unrelated images.
236
- MIN_OBJECT_SCORE = 0.45
237
- if score < MIN_OBJECT_SCORE:
238
- continue
239
  ui_score = score
240
-
241
- caption = "👤 Verified Identity" if is_face else match["metadata"].get("folder", "🎯 Object Match")
242
- out.append({
243
- "url": match["metadata"].get("url") or match["metadata"].get("image_url", ""), # "image_url" = legacy key from cloud_db.py
244
- "score": round(ui_score, 4),
245
- "caption": caption,
246
- })
247
  return out
248
 
249
- nested = await asyncio.gather(*[_query_one(v) for v in vectors])
250
  all_results = [r for sub in nested for r in sub]
251
-
252
  seen = {}
253
  for r in all_results:
254
  url = r["url"]
255
  if url not in seen or r["score"] > seen[url]["score"]:
256
  seen[url] = r
 
257
 
258
- return {"results": sorted(seen.values(), key=lambda x: x["score"], reverse=True)[:10]}
259
- except HTTPException:
260
- raise
 
 
 
 
 
 
261
  except Exception as e:
262
- print(f" Search error: {e}")
 
 
 
263
  raise HTTPException(500, str(e))
264
  finally:
265
  if os.path.exists(tmp_path): os.remove(tmp_path)
266
 
267
 
268
- # ══════════════════════════════════════════════════════════════════
269
- # 4. CATEGORIES
270
- # ══════════════════════════════════════════════════════════════════
271
  @app.post("/api/categories")
272
- async def get_categories(user_cloudinary_url: str = Form("")):
273
- actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
274
- if not actual_cld_url:
275
- return {"categories": []}
276
-
 
 
 
277
  try:
278
- creds = get_cloudinary_creds(actual_cld_url)
279
- if not creds.get("cloud_name"):
280
- return {"categories": []}
281
-
282
- result = await asyncio.to_thread(_cld_root_folders, creds)
283
- return {"categories": [f["name"] for f in result.get("folders", [])]}
 
284
  except Exception as e:
285
- print(f"Category fetch error: {e}")
286
  return {"categories": []}
287
 
288
 
289
  @app.get("/api/health")
290
  async def health():
291
- return {"status": "ok"}
292
- # ══════════════════════════════════════════════════════════════════
293
- # 5. LIST FOLDER IMAGES
294
- # ══════════════════════════════════════════════════════════════════
 
 
295
  def _cld_list_folder_images(folder: str, creds: dict, next_cursor: str = None):
296
- kwargs = dict(
297
- type="upload", prefix=f"{folder}/",
298
- max_results=500,
299
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
300
- )
301
- if next_cursor:
302
- kwargs["next_cursor"] = next_cursor
303
  return cloudinary.api.resources(**kwargs)
304
 
305
  @app.post("/api/cloudinary/folder-images")
306
  async def list_folder_images(
 
307
  user_cloudinary_url: str = Form(""),
308
- folder_name: str = Form(...),
 
309
  ):
310
- actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
311
- creds = get_cloudinary_creds(actual_cld_url)
312
- if not creds.get("cloud_name"):
313
- raise HTTPException(400, "Invalid Cloudinary URL.")
314
-
315
- images = []
316
- next_cursor = None
317
  while True:
318
- result = await asyncio.to_thread(_cld_list_folder_images, folder_name, creds, next_cursor)
319
  for r in result.get("resources", []):
320
  images.append({"url": r["secure_url"], "public_id": r["public_id"]})
321
  next_cursor = result.get("next_cursor")
322
- if not next_cursor:
323
- break
324
-
 
325
  return {"images": images, "count": len(images)}
326
 
327
 
328
- # ══════════════════════════════════════════════════════════════════
329
- # 6. DELETE SINGLE IMAGE
330
- # ═════════════════════════════════���═══════════════════════════════
331
  def url_to_public_id(image_url: str, cloud_name: str) -> str:
332
- """Extract Cloudinary public_id from secure_url."""
333
  try:
334
- path = urlparse(image_url).path
335
- parts = path.split("/")
336
- # Strip leading slash, cloud_name, delivery_type (image), access_mode (upload)
337
- # Format: /cloud_name/image/upload/[v12345/]folder/filename.ext
338
  upload_idx = parts.index("upload")
339
- after_upload = parts[upload_idx + 1:]
340
- # Strip version segment if present (starts with 'v' + digits)
341
- if after_upload and after_upload[0].startswith("v") and after_upload[0][1:].isdigit():
342
- after_upload = after_upload[1:]
343
- public_id_with_ext = "/".join(after_upload)
344
- # Strip file extension
345
- public_id = public_id_with_ext.rsplit(".", 1)[0]
346
- return public_id
347
- except Exception:
348
- return ""
349
 
350
  def _cld_delete_resource(public_id: str, creds: dict):
351
- return cloudinary.uploader.destroy(
352
- public_id,
353
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
354
- )
355
 
356
  @app.post("/api/delete-image")
357
  async def delete_image(
 
358
  user_pinecone_key: str = Form(""),
359
  user_cloudinary_url: str = Form(""),
360
  image_url: str = Form(""),
361
  public_id: str = Form(""),
 
362
  ):
363
- actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
 
364
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
365
- creds = get_cloudinary_creds(actual_cld_url)
366
- if not creds.get("cloud_name"):
367
- raise HTTPException(400, "Invalid Cloudinary URL.")
368
-
369
  pid = public_id or url_to_public_id(image_url, creds["cloud_name"])
370
- if not pid:
371
- raise HTTPException(400, "Could not determine public_id.")
372
-
373
- # Delete from Cloudinary
374
  await asyncio.to_thread(_cld_delete_resource, pid, creds)
375
-
376
- # Delete from Pinecone by metadata filter
377
  if actual_pc_key and image_url:
378
  try:
379
  pc = _get_pinecone(actual_pc_key)
380
  for idx_name in [IDX_OBJECTS, IDX_FACES]:
381
- idx = pc.Index(idx_name)
382
- await asyncio.to_thread(idx.delete, filter={"url": {"$eq": image_url}})
383
  except Exception as e:
384
- print(f"Pinecone delete warning: {e}")
385
-
 
 
386
  return {"message": "Image deleted successfully."}
387
 
388
 
389
- # ══════════════════════════════════════════════════════════════════
390
- # 7. DELETE ENTIRE FOLDER
391
- # ══════════════════════════════════════════════════════════════════
392
  def _cld_delete_folder(folder: str, creds: dict):
393
- return cloudinary.api.delete_resources_by_prefix(
394
- f"{folder}/",
395
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
396
- )
397
 
398
  def _cld_remove_folder(folder: str, creds: dict):
399
  try:
400
- return cloudinary.api.delete_folder(
401
- folder,
402
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
403
- )
404
- except Exception:
405
- pass # Folder may already be empty/gone
406
 
407
  @app.post("/api/delete-folder")
408
  async def delete_folder(
 
409
  user_pinecone_key: str = Form(""),
410
  user_cloudinary_url: str = Form(""),
411
  folder_name: str = Form(...),
 
412
  ):
413
- actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
 
414
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
415
- creds = get_cloudinary_creds(actual_cld_url)
416
- if not creds.get("cloud_name"):
417
- raise HTTPException(400, "Invalid Cloudinary URL.")
418
-
419
- # 1. List all images in folder first (for Pinecone cleanup)
420
- all_images = []
421
- next_cursor = None
422
  while True:
423
- result = await asyncio.to_thread(_cld_list_folder_images, folder_name, creds, next_cursor)
424
  all_images.extend(result.get("resources", []))
425
  next_cursor = result.get("next_cursor")
426
- if not next_cursor:
427
- break
428
-
429
- # 2. Delete all images from Cloudinary
430
  await asyncio.to_thread(_cld_delete_folder, folder_name, creds)
431
-
432
- # 3. Remove the folder itself from Cloudinary
433
  await asyncio.to_thread(_cld_remove_folder, folder_name, creds)
434
-
435
- # 4. Delete Pinecone vectors for each image
436
  if actual_pc_key:
437
  try:
438
  pc = _get_pinecone(actual_pc_key)
439
- # Try bulk delete by folder metadata filter first
440
  for idx_name in [IDX_OBJECTS, IDX_FACES]:
441
  idx = pc.Index(idx_name)
442
  try:
443
  await asyncio.to_thread(idx.delete, filter={"folder": {"$eq": folder_name}})
444
  except Exception:
445
- # Fallback: delete by individual image URLs
446
  for img in all_images:
447
  try:
448
- url = img.get("secure_url", "")
449
- if url:
450
- await asyncio.to_thread(idx.delete, filter={"url": {"$eq": url}})
451
- except Exception:
452
- pass
453
  except Exception as e:
454
- print(f"Pinecone folder delete warning: {e}")
455
-
 
 
456
  return {"message": f"Folder '{folder_name}' and all its contents deleted.", "deleted_count": len(all_images)}
457
 
458
 
459
- # ══════════════════════════════════════════════════════════════════
460
- # 8. RESET DATABASE
461
- # ══════════════════════════════════════════════════════════════════
462
- DEFAULT_PC_KEY = os.getenv("DEFAULT_PINECONE_KEY", "")
463
- DEFAULT_CLD_URL = os.getenv("DEFAULT_CLOUDINARY_URL","")
464
-
465
- def _is_default_key(key: str, default: str) -> bool:
466
- return bool(default) and key.strip() == default.strip()
467
-
468
  @app.post("/api/reset-database")
469
  async def reset_database(
 
470
  user_pinecone_key: str = Form(""),
471
  user_cloudinary_url: str = Form(""),
 
472
  ):
 
 
 
 
 
 
473
  if _is_default_key(user_pinecone_key, DEFAULT_PC_KEY) or _is_default_key(user_cloudinary_url, DEFAULT_CLD_URL):
 
 
474
  raise HTTPException(403, "Reset is not allowed on the shared demo database.")
475
 
476
  creds = get_cloudinary_creds(user_cloudinary_url)
477
- if not creds.get("cloud_name"):
478
- raise HTTPException(400, "Invalid Cloudinary URL.")
479
 
480
- # FIX #8: Wipe all Cloudinary resources AND delete folder objects
481
  try:
482
- await asyncio.to_thread(
483
- lambda: cloudinary.api.delete_all_resources(
484
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
485
- )
486
- )
487
- # Now delete the folder objects themselves
488
- try:
489
- folders_resp = await asyncio.to_thread(
490
- lambda: cloudinary.api.root_folders(
491
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"]
492
- )
493
- )
494
- folder_names = [f["path"] for f in folders_resp.get("folders", [])]
495
- for folder_path in folder_names:
496
- try:
497
- await asyncio.to_thread(
498
- lambda fp=folder_path: cloudinary.api.delete_folder(
499
- fp,
500
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"]
501
- )
502
- )
503
- except Exception as fe:
504
- print(f"Folder delete warning ({folder_path}): {fe}")
505
- except Exception as fe:
506
- print(f"Cloudinary folder list warning: {fe}")
507
  except Exception as e:
508
- print(f"Cloudinary wipe warning: {e}")
509
 
510
- # Delete and recreate Pinecone indexes
511
  try:
512
- pc = _get_pinecone(user_pinecone_key)
 
 
 
 
 
 
 
513
  existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
514
- delete_tasks = []
515
- if IDX_OBJECTS in existing:
516
- delete_tasks.append(asyncio.to_thread(pc.delete_index, IDX_OBJECTS))
517
- if IDX_FACES in existing:
518
- delete_tasks.append(asyncio.to_thread(pc.delete_index, IDX_FACES))
519
- if delete_tasks:
520
- await asyncio.gather(*delete_tasks)
521
  await asyncio.sleep(2)
522
  await asyncio.gather(
523
- asyncio.to_thread(pc.create_index, name=IDX_OBJECTS, dimension=1536, metric="cosine", spec=ServerlessSpec(cloud="aws", region="us-east-1")),
524
- asyncio.to_thread(pc.create_index, name=IDX_FACES, dimension=512, metric="cosine", spec=ServerlessSpec(cloud="aws", region="us-east-1")),
 
 
525
  )
526
  except Exception as e:
 
 
527
  raise HTTPException(500, f"Pinecone reset error: {e}")
528
 
 
 
 
529
  return {"message": "Database reset complete. All data wiped and indexes recreated."}
530
 
531
 
532
- # ══════════════════════════════════════════════════════════════════
533
- # 9. DELETE ACCOUNT
534
- # ═══════════���═════════════════════════════════════════════════════
535
  @app.post("/api/delete-account")
536
  async def delete_account(
 
537
  user_pinecone_key: str = Form(""),
538
  user_cloudinary_url: str = Form(""),
539
  user_id: str = Form(""),
540
  ):
 
 
 
 
 
 
541
  if _is_default_key(user_pinecone_key, DEFAULT_PC_KEY) or _is_default_key(user_cloudinary_url, DEFAULT_CLD_URL):
 
 
542
  raise HTTPException(403, "Account deletion is not allowed on the shared demo database.")
543
 
544
- # FIX #4: Full wipe — resources AND folder objects from Cloudinary
545
  creds = get_cloudinary_creds(user_cloudinary_url)
546
  try:
547
- await asyncio.to_thread(
548
- lambda: cloudinary.api.delete_all_resources(
549
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"],
550
- )
551
- )
552
- # Delete folder objects
553
- try:
554
- folders_resp = await asyncio.to_thread(
555
- lambda: cloudinary.api.root_folders(
556
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"]
557
- )
558
- )
559
- for f in folders_resp.get("folders", []):
560
- try:
561
- await asyncio.to_thread(
562
- lambda fp=f["path"]: cloudinary.api.delete_folder(
563
- fp,
564
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"]
565
- )
566
- )
567
- except Exception as fe:
568
- print(f"Folder delete warning: {fe}")
569
- except Exception as fe:
570
- print(f"Cloudinary folder list warning: {fe}")
571
  except Exception as e:
572
- print(f"Account delete Cloudinary warning: {e}")
573
 
574
  try:
575
- pc = _get_pinecone(user_pinecone_key)
576
  existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
577
- delete_tasks = []
578
- if IDX_OBJECTS in existing:
579
- delete_tasks.append(asyncio.to_thread(pc.delete_index, IDX_OBJECTS))
580
- if IDX_FACES in existing:
581
- delete_tasks.append(asyncio.to_thread(pc.delete_index, IDX_FACES))
582
- if delete_tasks:
583
- await asyncio.gather(*delete_tasks)
584
  except Exception as e:
585
- print(f"Account delete Pinecone warning: {e}")
586
 
 
 
 
587
  return {"message": "Account data deleted. Sign out initiated."}
 
3
  import shutil
4
  import uuid
5
  import re
6
+ import time
7
+ import json
8
+ import base64
9
+ import traceback
10
  import inflect
11
+ from datetime import datetime, timezone
12
  from urllib.parse import urlparse
13
  from typing import List
14
  from contextlib import asynccontextmanager
15
  from collections import OrderedDict
16
 
17
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request
18
  from fastapi.middleware.cors import CORSMiddleware
19
  import cloudinary
20
  import cloudinary.uploader
21
  import cloudinary.api
22
  from pinecone import Pinecone, ServerlessSpec
23
 
24
+ # ── loguru for pretty local console logs (optional dep) ──────────
25
+ try:
26
+ from loguru import logger as _loguru
27
+ _loguru.remove()
28
+ _loguru.add(
29
+ lambda msg: print(msg, end=""),
30
+ format="<green>{time:HH:mm:ss}</green> | <level>{level:<8}</level> | {message}",
31
+ level="DEBUG", colorize=True,
32
+ )
33
+ _log_fn = _loguru.log
34
+ except ImportError:
35
+ import logging as _logging
36
+ _logging.basicConfig(level=_logging.INFO)
37
+ _stdlib = _logging.getLogger("el")
38
+ def _log_fn(level, msg): _stdlib.log(getattr(_logging, level, 20), msg)
39
+
40
  # ── Deferred imports ─────────────────────────────────────────────
41
  ai = None
42
+ p = inflect.engine()
43
 
44
  MAX_CONCURRENT_INFERENCES = int(os.getenv("MAX_CONCURRENT_INFERENCES", "6"))
45
  _inference_sem: asyncio.Semaphore
 
46
  _pinecone_pool = OrderedDict()
47
+ _POOL_MAX = 64
48
+ IDX_FACES = "enterprise-faces"
49
+ IDX_OBJECTS = "enterprise-objects"
50
+
51
+ # ════════════════════════════════════════════════════════════════
52
+ # GRAFANA LOKI — async, fire-and-forget, never crashes the API
53
+ # HF Space Secrets needed:
54
+ # LOKI_URL → https://logs-prod-006.grafana.net (no trailing slash)
55
+ # LOKI_USERNAME → your Grafana Cloud numeric user ID
56
+ # LOKI_PASSWORD → your Grafana Cloud API token (Logs:Write scope)
57
+ # ════════════════════════════════════════════════════════════════
58
+ LOKI_URL = os.getenv("LOKI_URL", "")
59
+ LOKI_USERNAME = os.getenv("LOKI_USERNAME", "")
60
+ LOKI_PASSWORD = os.getenv("LOKI_PASSWORD", "")
61
+
62
+ async def _loki_push(level: str, event: str, data: dict):
63
+ """Fire-and-forget push to Grafana Loki. Silent on failure."""
64
+ if not (LOKI_URL and LOKI_USERNAME and LOKI_PASSWORD):
65
+ return
66
+ try:
67
+ import aiohttp
68
+ ts_ns = str(int(time.time() * 1e9))
69
+ line = json.dumps({"timestamp": datetime.now(timezone.utc).isoformat(),
70
+ "level": level.upper(), "service": "enterprise-lens",
71
+ "event": event, **data}, default=str)
72
+ payload = {"streams": [{"stream": {"service": "enterprise-lens",
73
+ "level": level.lower(),
74
+ "event": event,
75
+ "env": os.getenv("ENVIRONMENT", "production")},
76
+ "values": [[ts_ns, line]]}]}
77
+ creds = base64.b64encode(f"{LOKI_USERNAME}:{LOKI_PASSWORD}".encode()).decode()
78
+ headers = {"Content-Type": "application/json", "Authorization": f"Basic {creds}"}
79
+ async with aiohttp.ClientSession() as s:
80
+ async with s.post(f"{LOKI_URL}/loki/api/v1/push",
81
+ json=payload, headers=headers,
82
+ timeout=aiohttp.ClientTimeout(total=5)) as r:
83
+ if r.status not in (200, 204):
84
+ _log_fn("WARNING", f"Loki returned {r.status}")
85
+ except Exception as exc:
86
+ _log_fn("DEBUG", f"Loki push skipped: {exc}")
87
+
88
+ def log(level: str, event: str, **data):
89
+ """
90
+ Log to console + Grafana Loki (background task).
91
+ Usage: log("INFO", "upload.complete", user_id="x", files=3, duration_ms=340)
92
+ """
93
+ clean = {k: v for k, v in data.items()}
94
+ _log_fn(level.upper(), f"[{event}] {json.dumps(clean, default=str)}")
95
+ try:
96
+ loop = asyncio.get_event_loop()
97
+ if loop.is_running():
98
+ asyncio.create_task(_loki_push(level, event, data))
99
+ except Exception:
100
+ pass
101
+
102
+
103
+ # ════════════════════════════════════════════════════════════════
104
+ # HELPERS
105
+ # ════════════════════════════════════════════════════════════════
106
+ def get_ip(request: Request) -> str:
107
+ xff = request.headers.get("X-Forwarded-For", "")
108
+ return xff.split(",")[0].strip() if xff else getattr(request.client, "host", "unknown")
109
 
110
+ def is_guest(key: str) -> bool:
111
+ default = os.getenv("DEFAULT_PINECONE_KEY", "")
112
+ return bool(default) and key.strip() == default.strip()
113
 
114
  def _get_pinecone(api_key: str) -> Pinecone:
115
  if api_key not in _pinecone_pool:
 
119
  _pinecone_pool.move_to_end(api_key)
120
  return _pinecone_pool[api_key]
121
 
 
 
 
122
  def _cld_upload(tmp_path, folder, creds):
123
+ return cloudinary.uploader.upload(tmp_path, folder=folder,
124
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
 
 
125
 
126
  def _cld_ping(creds):
127
  return cloudinary.api.ping(
128
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
 
129
 
130
  def _cld_root_folders(creds):
131
  return cloudinary.api.root_folders(
132
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
133
+
134
+ def get_cloudinary_creds(env_url: str) -> dict:
135
+ if not env_url: return {}
136
+ parsed = urlparse(env_url)
137
+ return {"api_key": parsed.username, "api_secret": parsed.password, "cloud_name": parsed.hostname}
138
 
139
+ def standardize_category_name(name: str) -> str:
140
+ clean = re.sub(r'\s+', '_', name.strip().lower())
141
+ clean = re.sub(r'[^\w]', '', clean)
142
+ return p.singular_noun(clean) or clean
143
+
144
+ def sanitize_filename(filename: str) -> str:
145
+ return re.sub(r'[^\w.\-]', '', re.sub(r'\s+', '_', filename))
146
+
147
+ DEFAULT_PC_KEY = os.getenv("DEFAULT_PINECONE_KEY", "")
148
+ DEFAULT_CLD_URL = os.getenv("DEFAULT_CLOUDINARY_URL", "")
149
+
150
+ def _is_default_key(key: str, default: str) -> bool:
151
+ return bool(default) and key.strip() == default.strip()
152
+
153
+
154
+ # ════════════════════════════════════════════════════════════════
155
+ # APP STARTUP / SHUTDOWN
156
+ # ════════════════════════════════════════════════════════════════
157
  @asynccontextmanager
158
  async def lifespan(app: FastAPI):
159
  global ai, _inference_sem
160
  from src.models import AIModelManager
161
+ log("INFO", "server.startup", message="Loading AI models...")
 
162
  loop = asyncio.get_event_loop()
163
+ ai = await loop.run_in_executor(None, AIModelManager)
164
  _inference_sem = asyncio.Semaphore(MAX_CONCURRENT_INFERENCES)
165
+ log("INFO", "server.ready", message="All models loaded. API ready.")
166
  yield
167
+ log("INFO", "server.shutdown", message="API shutting down.")
168
 
169
  app = FastAPI(lifespan=lifespan)
170
+ app.add_middleware(CORSMiddleware,
171
+ allow_origins=["*"], allow_credentials=True,
172
+ allow_methods=["*"], allow_headers=["*"])
173
  os.makedirs("temp_uploads", exist_ok=True)
174
 
 
 
 
 
175
 
176
+ # ════════════════════════════════════════════════════════════════
177
+ # FRONTEND EVENT LOG — React calls this for client-side events
178
+ # Logs: page visits, tab switches, mode toggles, search/upload
179
+ # initiated, settings changes, errors caught in UI
180
+ # ════════════════════════════════════════════════════════════════
181
+ @app.post("/api/log")
182
+ async def frontend_log(
183
+ request: Request,
184
+ event: str = Form(...), # e.g. "page.visit", "search.initiated"
185
+ user_id: str = Form(""),
186
+ page: str = Form(""),
187
+ metadata: str = Form("{}"), # JSON string with extra context
188
+ ):
189
+ ip = get_ip(request)
190
+ try:
191
+ meta = json.loads(metadata) if metadata else {}
192
+ except Exception:
193
+ meta = {}
194
+ log("INFO", f"frontend.{event}",
195
+ user_id = user_id or "anonymous",
196
+ page = page,
197
+ ip = ip,
198
+ ua = request.headers.get("User-Agent", "")[:120],
199
+ **meta,
200
+ )
201
+ return {"ok": True}
202
 
 
 
 
 
 
203
 
204
+ # ════════════════════════════════════════════════════════════════
205
+ # 1. VERIFY KEYS & AUTO-BUILD INDEXES
206
+ # ════════════════════════════════════════════════════════════════
207
  @app.post("/api/verify-keys")
208
+ async def verify_keys(
209
+ request: Request,
210
+ pinecone_key: str = Form(""),
211
+ cloudinary_url: str = Form(""),
212
+ user_id: str = Form(""),
213
+ ):
214
+ ip = get_ip(request)
215
+ mode = "guest" if is_guest(pinecone_key) else "personal"
216
+ start = time.perf_counter()
217
+
218
+ log("INFO", "settings.verify_keys.start",
219
+ user_id=user_id or "anonymous", mode=mode, ip=ip)
220
+
221
  if cloudinary_url:
222
  try:
223
  creds_v = get_cloudinary_creds(cloudinary_url)
224
  if not creds_v.get("cloud_name"): raise ValueError("bad url")
225
  await asyncio.to_thread(_cld_ping, creds_v)
226
  except HTTPException: raise
227
+ except Exception as e:
228
+ log("ERROR", "settings.verify_keys.cloudinary_fail",
229
+ user_id=user_id or "anonymous", ip=ip, error=str(e),
230
+ duration_ms=round((time.perf_counter()-start)*1000))
231
  raise HTTPException(400, "Invalid Cloudinary Environment URL.")
232
+
233
+ indexes_created = []
234
  if pinecone_key:
235
  try:
236
+ pc = _get_pinecone(pinecone_key)
237
  existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
238
+ tasks = []
239
  if IDX_OBJECTS not in existing:
240
+ tasks.append(asyncio.to_thread(pc.create_index, name=IDX_OBJECTS, dimension=1536,
241
+ metric="cosine", spec=ServerlessSpec(cloud="aws", region="us-east-1")))
242
+ indexes_created.append(IDX_OBJECTS)
243
  if IDX_FACES not in existing:
244
+ tasks.append(asyncio.to_thread(pc.create_index, name=IDX_FACES, dimension=512,
245
+ metric="cosine", spec=ServerlessSpec(cloud="aws", region="us-east-1")))
246
+ indexes_created.append(IDX_FACES)
247
+ if tasks: await asyncio.gather(*tasks)
248
  except Exception as e:
249
+ err = str(e)
250
+ clean = ("Invalid Pinecone API Key. Please check your key and try again."
251
+ if "401" in err or "unauthorized" in err.lower()
252
+ else f"Pinecone Error: {err}")
253
+ log("ERROR", "settings.verify_keys.pinecone_fail",
254
+ user_id=user_id or "anonymous", ip=ip, error=clean,
255
+ duration_ms=round((time.perf_counter()-start)*1000))
256
+ raise HTTPException(400, clean)
257
+
258
+ log("INFO", "settings.verify_keys.success",
259
+ user_id=user_id or "anonymous", mode=mode, ip=ip,
260
+ indexes_created=indexes_created,
261
+ duration_ms=round((time.perf_counter()-start)*1000))
262
  return {"message": "Keys verified and indexes ready!"}
263
 
264
 
265
+ # ════════════════════════════════════════════════════════════════
266
+ # 2. UPLOAD
267
+ # ════════════════════════════════════════════════════════════════
268
  @app.post("/api/upload")
269
+ async def upload_new_images(
270
+ request: Request,
271
+ files: List[UploadFile] = File(...),
272
+ folder_name: str = Form(...),
273
+ detect_faces: bool = Form(True),
274
+ user_pinecone_key: str = Form(""),
275
+ user_cloudinary_url: str = Form(""),
276
+ user_id: str = Form(""),
277
+ ):
278
+ ip = get_ip(request)
279
+ start = time.perf_counter()
280
+ actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
281
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
282
+ mode = "guest" if is_guest(actual_pc_key) else "personal"
283
+
284
+ log("INFO", "upload.start",
285
+ user_id=user_id or "anonymous", ip=ip, mode=mode,
286
+ folder=folder_name, file_count=len(files),
287
+ file_names=[f.filename for f in files][:10],
288
+ detect_faces=detect_faces)
289
+
290
  if not actual_pc_key or not actual_cld_url:
291
+ log("ERROR", "upload.missing_keys", user_id=user_id or "anonymous", ip=ip, mode=mode)
292
  raise HTTPException(400, "API Keys are missing. If you are a guest, the server is missing its DEFAULT_ secrets in Hugging Face.")
293
 
294
  folder = standardize_category_name(folder_name)
295
+ creds = get_cloudinary_creds(actual_cld_url)
 
 
296
  if not creds.get("cloud_name"):
297
+ log("ERROR", "upload.bad_cloudinary_url", user_id=user_id or "anonymous", ip=ip)
298
  raise HTTPException(400, "Invalid Cloudinary URL format.")
299
+
300
+ pc = _get_pinecone(actual_pc_key)
301
+ idx_obj = pc.Index(IDX_OBJECTS)
302
+ idx_face = pc.Index(IDX_FACES)
303
+ uploaded_urls = []
304
+ face_vec_total = 0
305
+ object_vec_total = 0
306
 
307
  for file in files:
308
+ tmp_path = f"temp_uploads/{uuid.uuid4().hex}_{sanitize_filename(file.filename)}"
309
+ file_start = time.perf_counter()
310
  try:
311
  with open(tmp_path, "wb") as buf:
312
  shutil.copyfileobj(file.file, buf)
313
+
314
+ res = await asyncio.to_thread(_cld_upload, tmp_path, folder, creds)
315
  image_url = res["secure_url"]
316
  uploaded_urls.append(image_url)
317
 
 
321
  face_upserts, object_upserts = [], []
322
  for v in vectors:
323
  vec_list = v["vector"].tolist() if hasattr(v["vector"], "tolist") else v["vector"]
324
+ record = {"id": str(uuid.uuid4()), "values": vec_list,
325
+ "metadata": {"url": image_url, "folder": folder}}
326
  (face_upserts if v["type"] == "face" else object_upserts).append(record)
327
 
328
+ face_vec_total += len(face_upserts)
329
+ object_vec_total += len(object_upserts)
330
+
331
  upsert_tasks = []
332
+ if face_upserts: upsert_tasks.append(asyncio.to_thread(idx_face.upsert, vectors=face_upserts))
333
+ if object_upserts: upsert_tasks.append(asyncio.to_thread(idx_obj.upsert, vectors=object_upserts))
334
+ if upsert_tasks: await asyncio.gather(*upsert_tasks)
335
+
336
+ log("INFO", "upload.file.success",
337
+ user_id=user_id or "anonymous", ip=ip, mode=mode,
338
+ filename=file.filename, folder=folder, image_url=image_url,
339
+ face_vectors=len(face_upserts), obj_vectors=len(object_upserts),
340
+ detect_faces=detect_faces,
341
+ duration_ms=round((time.perf_counter()-file_start)*1000))
342
+
343
  except Exception as e:
344
+ log("ERROR", "upload.file.error",
345
+ user_id=user_id or "anonymous", ip=ip, mode=mode,
346
+ filename=file.filename, folder=folder, error=str(e),
347
+ traceback=traceback.format_exc()[-800:],
348
+ duration_ms=round((time.perf_counter()-file_start)*1000))
349
+ err = str(e)
350
+ if "not found" in err.lower() or "404" in err:
351
  raise HTTPException(404, "Pinecone index not found. Please go to Settings and click 'Verify & Save' to recreate your indexes.")
352
+ raise HTTPException(500, f"Upload processing failed: {err}")
 
 
353
  finally:
354
  if os.path.exists(tmp_path): os.remove(tmp_path)
355
+
356
+ log("INFO", "upload.complete",
357
+ user_id=user_id or "anonymous", ip=ip, mode=mode,
358
+ folder=folder, files_uploaded=len(uploaded_urls),
359
+ face_vectors=face_vec_total, object_vectors=object_vec_total,
360
+ detect_faces=detect_faces,
361
+ duration_ms=round((time.perf_counter()-start)*1000))
362
+
363
  return {"message": "Done!", "urls": uploaded_urls}
364
 
365
 
366
+ # ════════════════════════════════════════════════════════════════
367
+ # 3. SEARCH
368
+ # ════════════════════════════════════════════════════════════════
369
  @app.post("/api/search")
370
+ async def search_database(
371
+ request: Request,
372
+ file: UploadFile = File(...),
373
+ detect_faces: bool = Form(True),
374
+ user_pinecone_key: str = Form(""),
375
+ user_cloudinary_url: str = Form(""),
376
+ user_id: str = Form(""),
377
+ ):
378
+ ip = get_ip(request)
379
+ start = time.perf_counter()
380
  actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
381
+ mode = "guest" if is_guest(actual_pc_key) else "personal"
382
+
383
+ log("INFO", "search.start",
384
+ user_id=user_id or "anonymous", ip=ip, mode=mode,
385
+ filename=file.filename, detect_faces=detect_faces)
386
+
387
  if not actual_pc_key:
388
+ log("ERROR", "search.missing_keys", user_id=user_id or "anonymous", ip=ip, mode=mode)
389
+ raise HTTPException(400, "Pinecone Key is missing.")
390
 
391
  tmp_path = f"temp_uploads/query_{uuid.uuid4().hex}_{sanitize_filename(file.filename)}"
392
  try:
 
396
  async with _inference_sem:
397
  vectors = await ai.process_image_async(tmp_path, is_query=True, detect_faces=detect_faces)
398
 
399
+ inference_ms = round((time.perf_counter() - start) * 1000)
400
+ lanes_used = list({v["type"] for v in vectors})
401
+ log("INFO", "search.inference_done",
402
+ user_id=user_id or "anonymous", ip=ip, mode=mode,
403
+ vector_count=len(vectors), lanes=lanes_used, inference_ms=inference_ms)
404
+
405
+ pc = _get_pinecone(actual_pc_key)
406
+ idx_obj = pc.Index(IDX_OBJECTS)
407
  idx_face = pc.Index(IDX_FACES)
408
 
409
  async def _query_one(vec_dict: dict):
410
+ vec_list = vec_dict["vector"].tolist() if hasattr(vec_dict["vector"], "tolist") else vec_dict["vector"]
411
  target_idx = idx_face if vec_dict["type"] == "face" else idx_obj
 
412
  try:
413
  res = await asyncio.to_thread(target_idx.query, vector=vec_list, top_k=10, include_metadata=True)
414
  except Exception as e:
415
  if "404" in str(e):
416
+ raise HTTPException(404, "Pinecone Index not found. Please log in and click 'Verify Keys' in Settings.")
417
  raise e
 
418
  out = []
419
  for match in res.get("matches", []):
420
+ score = match["score"]
421
  is_face = vec_dict["type"] == "face"
 
 
 
 
422
  if is_face:
423
+ if score < 0.35: continue
424
+ ui_score = min(0.99, 0.75 + ((score - 0.35) / 0.65) * 0.24)
 
 
 
425
  else:
426
+ if score < 0.45: continue
 
 
 
 
 
427
  ui_score = score
428
+ out.append({"url": match["metadata"].get("url") or match["metadata"].get("image_url", ""),
429
+ "score": round(ui_score, 4),
430
+ "caption": "👤 Verified Identity" if is_face else match["metadata"].get("folder", "🎯 Object Match")})
 
 
 
 
431
  return out
432
 
433
+ nested = await asyncio.gather(*[_query_one(v) for v in vectors])
434
  all_results = [r for sub in nested for r in sub]
 
435
  seen = {}
436
  for r in all_results:
437
  url = r["url"]
438
  if url not in seen or r["score"] > seen[url]["score"]:
439
  seen[url] = r
440
+ final = sorted(seen.values(), key=lambda x: x["score"], reverse=True)[:10]
441
 
442
+ log("INFO", "search.complete",
443
+ user_id=user_id or "anonymous", ip=ip, mode=mode,
444
+ lanes=lanes_used, detect_faces=detect_faces,
445
+ results_count=len(final), top_score=final[0]["score"] if final else 0,
446
+ duration_ms=round((time.perf_counter()-start)*1000))
447
+
448
+ return {"results": final}
449
+
450
+ except HTTPException: raise
451
  except Exception as e:
452
+ log("ERROR", "search.error",
453
+ user_id=user_id or "anonymous", ip=ip, mode=mode,
454
+ error=str(e), traceback=traceback.format_exc()[-800:],
455
+ duration_ms=round((time.perf_counter()-start)*1000))
456
  raise HTTPException(500, str(e))
457
  finally:
458
  if os.path.exists(tmp_path): os.remove(tmp_path)
459
 
460
 
461
+ # ════════════════════════════════════════════════════════════════
462
+ # 4. CATEGORIES
463
+ # ════════════════════════════════════════════════════════════════
464
  @app.post("/api/categories")
465
+ async def get_categories(
466
+ request: Request,
467
+ user_cloudinary_url: str = Form(""),
468
+ user_id: str = Form(""),
469
+ ):
470
+ ip = get_ip(request)
471
+ actual_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
472
+ if not actual_url: return {"categories": []}
473
  try:
474
+ creds = get_cloudinary_creds(actual_url)
475
+ if not creds.get("cloud_name"): return {"categories": []}
476
+ result = await asyncio.to_thread(_cld_root_folders, creds)
477
+ categories = [f["name"] for f in result.get("folders", [])]
478
+ log("INFO", "categories.fetched",
479
+ user_id=user_id or "anonymous", ip=ip, category_count=len(categories))
480
+ return {"categories": categories}
481
  except Exception as e:
482
+ log("ERROR", "categories.error", user_id=user_id or "anonymous", ip=ip, error=str(e))
483
  return {"categories": []}
484
 
485
 
486
  @app.get("/api/health")
487
  async def health():
488
+ return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
489
+
490
+
491
+ # ════════════════════════════════════════════════════════════════
492
+ # 5. LIST FOLDER IMAGES
493
+ # ════════════════════════════════════════════════════════════════
494
  def _cld_list_folder_images(folder: str, creds: dict, next_cursor: str = None):
495
+ kwargs = dict(type="upload", prefix=f"{folder}/", max_results=500,
496
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
497
+ if next_cursor: kwargs["next_cursor"] = next_cursor
 
 
 
 
498
  return cloudinary.api.resources(**kwargs)
499
 
500
  @app.post("/api/cloudinary/folder-images")
501
  async def list_folder_images(
502
+ request: Request,
503
  user_cloudinary_url: str = Form(""),
504
+ folder_name: str = Form(...),
505
+ user_id: str = Form(""),
506
  ):
507
+ ip = get_ip(request)
508
+ actual_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
509
+ creds = get_cloudinary_creds(actual_url)
510
+ if not creds.get("cloud_name"): raise HTTPException(400, "Invalid Cloudinary URL.")
511
+ images, next_cursor = [], None
 
 
512
  while True:
513
+ result = await asyncio.to_thread(_cld_list_folder_images, folder_name, creds, next_cursor)
514
  for r in result.get("resources", []):
515
  images.append({"url": r["secure_url"], "public_id": r["public_id"]})
516
  next_cursor = result.get("next_cursor")
517
+ if not next_cursor: break
518
+ log("INFO", "explorer.folder_opened",
519
+ user_id=user_id or "anonymous", ip=ip,
520
+ folder_name=folder_name, image_count=len(images))
521
  return {"images": images, "count": len(images)}
522
 
523
 
524
+ # ════════════════════════════════════════════════════════════════
525
+ # 6. DELETE SINGLE IMAGE
526
+ # ════════════════════════════════════════════════════════════════
527
  def url_to_public_id(image_url: str, cloud_name: str) -> str:
 
528
  try:
529
+ path = urlparse(image_url).path
530
+ parts = path.split("/")
 
 
531
  upload_idx = parts.index("upload")
532
+ after = parts[upload_idx + 1:]
533
+ if after and after[0].startswith("v") and after[0][1:].isdigit(): after = after[1:]
534
+ return "/".join(after).rsplit(".", 1)[0]
535
+ except Exception: return ""
 
 
 
 
 
 
536
 
537
  def _cld_delete_resource(public_id: str, creds: dict):
538
+ return cloudinary.uploader.destroy(public_id,
539
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
 
 
540
 
541
  @app.post("/api/delete-image")
542
  async def delete_image(
543
+ request: Request,
544
  user_pinecone_key: str = Form(""),
545
  user_cloudinary_url: str = Form(""),
546
  image_url: str = Form(""),
547
  public_id: str = Form(""),
548
+ user_id: str = Form(""),
549
  ):
550
+ ip = get_ip(request)
551
+ actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
552
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
553
+ creds = get_cloudinary_creds(actual_cld_url)
554
+ if not creds.get("cloud_name"): raise HTTPException(400, "Invalid Cloudinary URL.")
 
 
555
  pid = public_id or url_to_public_id(image_url, creds["cloud_name"])
556
+ if not pid: raise HTTPException(400, "Could not determine public_id.")
 
 
 
557
  await asyncio.to_thread(_cld_delete_resource, pid, creds)
 
 
558
  if actual_pc_key and image_url:
559
  try:
560
  pc = _get_pinecone(actual_pc_key)
561
  for idx_name in [IDX_OBJECTS, IDX_FACES]:
562
+ await asyncio.to_thread(pc.Index(idx_name).delete, filter={"url": {"$eq": image_url}})
 
563
  except Exception as e:
564
+ _log_fn("WARNING", f"Pinecone delete warning: {e}")
565
+ log("INFO", "explorer.image_deleted",
566
+ user_id=user_id or "anonymous", ip=ip,
567
+ image_url=image_url, public_id=pid)
568
  return {"message": "Image deleted successfully."}
569
 
570
 
571
+ # ════════════════════════════════════════════════════════════════
572
+ # 7. DELETE ENTIRE FOLDER
573
+ # ════════════════════════════════════════════════════════════════
574
  def _cld_delete_folder(folder: str, creds: dict):
575
+ return cloudinary.api.delete_resources_by_prefix(f"{folder}/",
576
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
 
 
577
 
578
  def _cld_remove_folder(folder: str, creds: dict):
579
  try:
580
+ return cloudinary.api.delete_folder(folder,
581
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
582
+ except Exception: pass
 
 
 
583
 
584
  @app.post("/api/delete-folder")
585
  async def delete_folder(
586
+ request: Request,
587
  user_pinecone_key: str = Form(""),
588
  user_cloudinary_url: str = Form(""),
589
  folder_name: str = Form(...),
590
+ user_id: str = Form(""),
591
  ):
592
+ ip = get_ip(request)
593
+ actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
594
  actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
595
+ creds = get_cloudinary_creds(actual_cld_url)
596
+ if not creds.get("cloud_name"): raise HTTPException(400, "Invalid Cloudinary URL.")
597
+ all_images, next_cursor = [], None
 
 
 
 
598
  while True:
599
+ result = await asyncio.to_thread(_cld_list_folder_images, folder_name, creds, next_cursor)
600
  all_images.extend(result.get("resources", []))
601
  next_cursor = result.get("next_cursor")
602
+ if not next_cursor: break
 
 
 
603
  await asyncio.to_thread(_cld_delete_folder, folder_name, creds)
 
 
604
  await asyncio.to_thread(_cld_remove_folder, folder_name, creds)
 
 
605
  if actual_pc_key:
606
  try:
607
  pc = _get_pinecone(actual_pc_key)
 
608
  for idx_name in [IDX_OBJECTS, IDX_FACES]:
609
  idx = pc.Index(idx_name)
610
  try:
611
  await asyncio.to_thread(idx.delete, filter={"folder": {"$eq": folder_name}})
612
  except Exception:
 
613
  for img in all_images:
614
  try:
615
+ if img.get("secure_url"):
616
+ await asyncio.to_thread(idx.delete, filter={"url": {"$eq": img["secure_url"]}})
617
+ except Exception: pass
 
 
618
  except Exception as e:
619
+ _log_fn("WARNING", f"Pinecone folder delete warning: {e}")
620
+ log("INFO", "explorer.folder_deleted",
621
+ user_id=user_id or "anonymous", ip=ip,
622
+ folder_name=folder_name, deleted_count=len(all_images))
623
  return {"message": f"Folder '{folder_name}' and all its contents deleted.", "deleted_count": len(all_images)}
624
 
625
 
626
+ # ════════════════════════════════════════════════════════════════
627
+ # 8. RESET DATABASE ⚠️ DESTRUCTIVE — triple-logged
628
+ # ════════════════════════════════════════════════════════════════
 
 
 
 
 
 
629
  @app.post("/api/reset-database")
630
  async def reset_database(
631
+ request: Request,
632
  user_pinecone_key: str = Form(""),
633
  user_cloudinary_url: str = Form(""),
634
+ user_id: str = Form(""),
635
  ):
636
+ ip = get_ip(request)
637
+ start = time.perf_counter()
638
+
639
+ log("WARNING", "danger.reset_database.attempt",
640
+ user_id=user_id or "anonymous", ip=ip)
641
+
642
  if _is_default_key(user_pinecone_key, DEFAULT_PC_KEY) or _is_default_key(user_cloudinary_url, DEFAULT_CLD_URL):
643
+ log("WARNING", "danger.reset_database.blocked_shared_db",
644
+ user_id=user_id or "anonymous", ip=ip)
645
  raise HTTPException(403, "Reset is not allowed on the shared demo database.")
646
 
647
  creds = get_cloudinary_creds(user_cloudinary_url)
648
+ if not creds.get("cloud_name"): raise HTTPException(400, "Invalid Cloudinary URL.")
 
649
 
 
650
  try:
651
+ await asyncio.to_thread(lambda: cloudinary.api.delete_all_resources(
652
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
  except Exception as e:
654
+ _log_fn("WARNING", f"Cloudinary wipe: {e}")
655
 
656
+ # Delete Cloudinary folders too
657
  try:
658
+ folders_res = await asyncio.to_thread(_cld_root_folders, creds)
659
+ for folder in folders_res.get("folders", []):
660
+ await asyncio.to_thread(_cld_remove_folder, folder["name"], creds)
661
+ except Exception as e:
662
+ _log_fn("WARNING", f"Cloudinary folder cleanup: {e}")
663
+
664
+ try:
665
+ pc = _get_pinecone(user_pinecone_key)
666
  existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
667
+ tasks = []
668
+ if IDX_OBJECTS in existing: tasks.append(asyncio.to_thread(pc.delete_index, IDX_OBJECTS))
669
+ if IDX_FACES in existing: tasks.append(asyncio.to_thread(pc.delete_index, IDX_FACES))
670
+ if tasks: await asyncio.gather(*tasks)
 
 
 
671
  await asyncio.sleep(2)
672
  await asyncio.gather(
673
+ asyncio.to_thread(pc.create_index, name=IDX_OBJECTS, dimension=1536, metric="cosine",
674
+ spec=ServerlessSpec(cloud="aws", region="us-east-1")),
675
+ asyncio.to_thread(pc.create_index, name=IDX_FACES, dimension=512, metric="cosine",
676
+ spec=ServerlessSpec(cloud="aws", region="us-east-1")),
677
  )
678
  except Exception as e:
679
+ log("ERROR", "danger.reset_database.pinecone_error",
680
+ user_id=user_id or "anonymous", ip=ip, error=str(e))
681
  raise HTTPException(500, f"Pinecone reset error: {e}")
682
 
683
+ log("WARNING", "danger.reset_database.complete",
684
+ user_id=user_id or "anonymous", ip=ip,
685
+ duration_ms=round((time.perf_counter()-start)*1000))
686
  return {"message": "Database reset complete. All data wiped and indexes recreated."}
687
 
688
 
689
+ # ════════════════════════════════════════════════════════════════
690
+ # 9. DELETE ACCOUNT ⚠️ DESTRUCTIVE — triple-logged
691
+ # ════════════════════════════════════════════════════════════════
692
  @app.post("/api/delete-account")
693
  async def delete_account(
694
+ request: Request,
695
  user_pinecone_key: str = Form(""),
696
  user_cloudinary_url: str = Form(""),
697
  user_id: str = Form(""),
698
  ):
699
+ ip = get_ip(request)
700
+ start = time.perf_counter()
701
+
702
+ log("WARNING", "danger.delete_account.attempt",
703
+ user_id=user_id or "anonymous", ip=ip)
704
+
705
  if _is_default_key(user_pinecone_key, DEFAULT_PC_KEY) or _is_default_key(user_cloudinary_url, DEFAULT_CLD_URL):
706
+ log("WARNING", "danger.delete_account.blocked_shared_db",
707
+ user_id=user_id or "anonymous", ip=ip)
708
  raise HTTPException(403, "Account deletion is not allowed on the shared demo database.")
709
 
 
710
  creds = get_cloudinary_creds(user_cloudinary_url)
711
  try:
712
+ await asyncio.to_thread(lambda: cloudinary.api.delete_all_resources(
713
+ api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"]))
714
+ except Exception as e:
715
+ _log_fn("WARNING", f"Account delete Cloudinary: {e}")
716
+
717
+ # Delete Cloudinary folders
718
+ try:
719
+ folders_res = await asyncio.to_thread(_cld_root_folders, creds)
720
+ for folder in folders_res.get("folders", []):
721
+ await asyncio.to_thread(_cld_remove_folder, folder["name"], creds)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
722
  except Exception as e:
723
+ _log_fn("WARNING", f"Account delete Cloudinary folders: {e}")
724
 
725
  try:
726
+ pc = _get_pinecone(user_pinecone_key)
727
  existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
728
+ tasks = []
729
+ if IDX_OBJECTS in existing: tasks.append(asyncio.to_thread(pc.delete_index, IDX_OBJECTS))
730
+ if IDX_FACES in existing: tasks.append(asyncio.to_thread(pc.delete_index, IDX_FACES))
731
+ if tasks: await asyncio.gather(*tasks)
 
 
 
732
  except Exception as e:
733
+ _log_fn("WARNING", f"Account delete Pinecone: {e}")
734
 
735
+ log("WARNING", "danger.delete_account.complete",
736
+ user_id=user_id or "anonymous", ip=ip,
737
+ duration_ms=round((time.perf_counter()-start)*1000))
738
  return {"message": "Account data deleted. Sign out initiated."}