AdarshDRC commited on
Commit
53808b4
·
verified ·
1 Parent(s): 6c6ffa6

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +487 -692
main.py CHANGED
@@ -1,26 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import asyncio
 
 
2
  import os
3
  import shutil
4
- import uuid
5
- import re
6
  import time
7
- import json
8
  import traceback
9
- import inflect
 
 
10
  from datetime import datetime, timezone
11
- from urllib.parse import urlparse
12
  from typing import List
13
- from contextlib import asynccontextmanager
14
- from collections import OrderedDict
15
 
16
- from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request
 
17
  from fastapi.middleware.cors import CORSMiddleware
18
- import cloudinary
19
- import cloudinary.uploader
20
- import cloudinary.api
21
- from pinecone import Pinecone, ServerlessSpec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- # ── loguru for pretty local console logs (optional dep) ──────────
24
  try:
25
  from loguru import logger as _loguru
26
  _loguru.remove()
@@ -33,46 +74,13 @@ try:
33
  except ImportError:
34
  import logging as _logging
35
  _logging.basicConfig(level=_logging.INFO)
36
- _stdlib = _logging.getLogger("el")
37
- def _log_fn(level, msg): _stdlib.log(getattr(_logging, level, 20), msg)
38
-
39
- # ── Deferred imports ─────────────────────────────────────────────
40
- ai = None
41
- p = inflect.engine()
42
-
43
- MAX_CONCURRENT_INFERENCES = int(os.getenv("MAX_CONCURRENT_INFERENCES", "1")) # InsightFace ONNX is NOT thread-safe
44
- _inference_sem: asyncio.Semaphore
45
- _pinecone_pool = OrderedDict()
46
- _POOL_MAX = 64
47
- IDX_FACES = "enterprise-faces"
48
- MAX_FILES_PER_UPLOAD = 20 # cap to prevent memory corruption on large batches
49
- IDX_OBJECTS = "enterprise-objects"
50
-
51
- # ── V4 index dimensions ───────────────────────────────────────────
52
- # enterprise-faces : 1024-D (ArcFace 512 + AdaFace 512, fused)
53
- # enterprise-objects: 1536-D (SigLIP 768 + DINOv2 768, fused)
54
- # ⚠️ If upgrading from V3 (512-D faces), you MUST reset the
55
- # enterprise-faces index via Settings → Danger Zone → Reset DB
56
- IDX_FACES_DIM = int(os.getenv("IDX_FACES_DIM", "1024"))
57
- IDX_OBJECTS_DIM = int(os.getenv("IDX_OBJECTS_DIM", "1536"))
58
-
59
- # V4 face search thresholds
60
- # Cosine similarity thresholds for the fused 1024-D ArcFace+AdaFace space
61
- FACE_THRESHOLD_HIGH = 0.40 # high-quality faces (det_score ≥ 0.85)
62
- FACE_THRESHOLD_LOW = 0.32 # lower-quality faces (det_score < 0.85)
63
- FACE_TOP_K_FETCH = 50 # fetch more candidates, filter after merge
64
-
65
- # ════════════════════════════════════════════════════════════════
66
- # SUPABASE LOGGING — async, fire-and-forget, never crashes API
67
- # HF Space Secrets needed:
68
- # SUPABASE_URL → https://xxxx.supabase.co
69
- # SUPABASE_SERVICE_KEY → your Supabase service_role key (not anon!)
70
- # ════════════════════════════════════════════════════════════════
71
- SUPABASE_URL = os.getenv("SUPABASE_URL", "")
72
- SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY", "")
73
-
74
- async def _supabase_log_push(level: str, event: str, data: dict):
75
- """Fire-and-forget insert into Supabase app_logs table."""
76
  if not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
77
  return
78
  try:
@@ -85,7 +93,7 @@ async def _supabase_log_push(level: str, event: str, data: dict):
85
  "mode": str(data.get("mode", "")),
86
  "page": str(data.get("page", "")),
87
  "duration_ms": int(data["duration_ms"]) if "duration_ms" in data else None,
88
- "error": str(data["error"]) if "error" in data else None,
89
  "data": data,
90
  }
91
  headers = {
@@ -98,133 +106,101 @@ async def _supabase_log_push(level: str, event: str, data: dict):
98
  async with s.post(
99
  f"{SUPABASE_URL}/rest/v1/app_logs",
100
  json=row, headers=headers,
101
- timeout=aiohttp.ClientTimeout(total=5)
102
  ) as r:
103
  if r.status not in (200, 201):
104
  body = await r.text()
105
- _log_fn("WARNING", f"Supabase log insert failed {r.status}: {body[:200]}")
106
  except Exception as exc:
107
  _log_fn("DEBUG", f"Supabase log push skipped: {exc}")
108
 
 
109
  def log(level: str, event: str, **data):
110
- """
111
- Log to console + Supabase app_logs table (background task).
112
- Usage: log("INFO", "upload.complete", user_id="x", files=3, duration_ms=340)
113
- """
114
- clean = {k: v for k, v in data.items()}
115
- _log_fn(level.upper(), f"[{event}] {json.dumps(clean, default=str)}")
116
  try:
117
  loop = asyncio.get_event_loop()
118
  if loop.is_running():
119
- asyncio.create_task(_supabase_log_push(level, event, data))
120
  except Exception:
121
  pass
122
 
123
 
124
- # ════════════════════════════════════════════════════════════════
125
- # HELPERS
126
- # ════════════════════════════════════════════════════════════════
127
- def get_ip(request: Request) -> str:
128
- xff = request.headers.get("X-Forwarded-For", "")
129
- return xff.split(",")[0].strip() if xff else getattr(request.client, "host", "unknown")
130
-
131
- def is_guest(key: str) -> bool:
132
- default = os.getenv("DEFAULT_PINECONE_KEY", "")
133
- return bool(default) and key.strip() == default.strip()
134
-
135
- def _get_pinecone(api_key: str) -> Pinecone:
136
- if api_key not in _pinecone_pool:
137
- if len(_pinecone_pool) >= _POOL_MAX:
138
- _pinecone_pool.popitem(last=False)
139
- _pinecone_pool[api_key] = Pinecone(api_key=api_key)
140
- _pinecone_pool.move_to_end(api_key)
141
- return _pinecone_pool[api_key]
142
-
143
- def _cld_upload(tmp_path, folder, creds):
144
- return cloudinary.uploader.upload(tmp_path, folder=folder,
145
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
146
 
147
- def _cld_ping(creds):
148
- return cloudinary.api.ping(
149
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
150
-
151
- def _cld_root_folders(creds):
152
- return cloudinary.api.root_folders(
153
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
154
-
155
- def get_cloudinary_creds(env_url: str) -> dict:
156
- if not env_url: return {}
157
- parsed = urlparse(env_url)
158
- return {"api_key": parsed.username, "api_secret": parsed.password, "cloud_name": parsed.hostname}
159
-
160
- def standardize_category_name(name: str) -> str:
161
- clean = re.sub(r'\s+', '_', name.strip().lower())
162
- clean = re.sub(r'[^\w]', '', clean)
163
- return p.singular_noun(clean) or clean
164
-
165
- def sanitize_filename(filename: str) -> str:
166
- return re.sub(r'[^\w.\-]', '', re.sub(r'\s+', '_', filename))
167
-
168
- DEFAULT_PC_KEY = os.getenv("DEFAULT_PINECONE_KEY", "")
169
- DEFAULT_CLD_URL = os.getenv("DEFAULT_CLOUDINARY_URL", "")
170
-
171
- def _is_default_key(key: str, default: str) -> bool:
172
- return bool(default) and key.strip() == default.strip()
173
-
174
-
175
- # ════════════════════════════════════════════════════════════════
176
- # APP STARTUP / SHUTDOWN
177
- # ════════════════════════════════════════════════════════════════
178
  @asynccontextmanager
179
  async def lifespan(app: FastAPI):
180
- global ai, _inference_sem
 
 
 
 
 
181
  from src.models import AIModelManager
182
  log("INFO", "server.startup", message="Loading AI models...")
183
  loop = asyncio.get_event_loop()
184
- ai = await loop.run_in_executor(None, AIModelManager)
185
- _inference_sem = asyncio.Semaphore(MAX_CONCURRENT_INFERENCES)
186
  log("INFO", "server.ready", message="All models loaded. API ready.")
187
  yield
188
  log("INFO", "server.shutdown", message="API shutting down.")
189
 
 
190
  app = FastAPI(lifespan=lifespan)
191
- app.add_middleware(CORSMiddleware,
 
192
  allow_origins=["*"], allow_credentials=True,
193
- allow_methods=["*"], allow_headers=["*"])
 
194
  os.makedirs("temp_uploads", exist_ok=True)
195
 
196
 
197
- # ════════════════════════════════════════════════════════════════
198
- # FRONTEND EVENT LOG — React calls this for client-side events
199
- # Logs: page visits, tab switches, mode toggles, search/upload
200
- # initiated, settings changes, errors caught in UI
201
- # ════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  @app.post("/api/log")
203
  async def frontend_log(
204
  request: Request,
205
- event: str = Form(...), # e.g. "page.visit", "search.initiated"
206
  user_id: str = Form(""),
207
  page: str = Form(""),
208
- metadata: str = Form("{}"), # JSON string with extra context
209
  ):
 
210
  ip = get_ip(request)
211
  try:
212
  meta = json.loads(metadata) if metadata else {}
213
  except Exception:
214
  meta = {}
215
  log("INFO", f"frontend.{event}",
216
- user_id = user_id or "anonymous",
217
- page = page,
218
- ip = ip,
219
- ua = request.headers.get("User-Agent", "")[:120],
220
- **meta,
221
- )
222
  return {"ok": True}
223
 
224
 
225
- # ════════════════════════════════════════════════════════════════
226
  # 1. VERIFY KEYS & AUTO-BUILD INDEXES
227
- # ════════════════════════════════════════════════════════════════
 
228
  @app.post("/api/verify-keys")
229
  async def verify_keys(
230
  request: Request,
@@ -233,199 +209,193 @@ async def verify_keys(
233
  user_id: str = Form(""),
234
  ):
235
  ip = get_ip(request)
236
- mode = "guest" if is_guest(pinecone_key) else "personal"
237
  start = time.perf_counter()
238
-
239
- log("INFO", "settings.verify_keys.start",
240
- user_id=user_id or "anonymous", mode=mode, ip=ip)
241
 
242
  if cloudinary_url:
 
 
 
243
  try:
244
- creds_v = get_cloudinary_creds(cloudinary_url)
245
- if not creds_v.get("cloud_name"): raise ValueError("bad url")
246
- await asyncio.to_thread(_cld_ping, creds_v)
247
- except HTTPException: raise
248
  except Exception as e:
249
  log("ERROR", "settings.verify_keys.cloudinary_fail",
250
- user_id=user_id or "anonymous", ip=ip, error=str(e),
251
- duration_ms=round((time.perf_counter()-start)*1000))
252
  raise HTTPException(400, "Invalid Cloudinary Environment URL.")
253
 
254
  indexes_created = []
255
  if pinecone_key:
256
  try:
257
- pc = _get_pinecone(pinecone_key)
258
- existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
259
- tasks = []
260
- if IDX_OBJECTS not in existing:
261
- tasks.append(asyncio.to_thread(pc.create_index, name=IDX_OBJECTS,
262
- dimension=IDX_OBJECTS_DIM, # 1536-D SigLIP+DINOv2
263
- metric="cosine", spec=ServerlessSpec(cloud="aws", region="us-east-1")))
264
- indexes_created.append(IDX_OBJECTS)
265
- if IDX_FACES not in existing:
266
- tasks.append(asyncio.to_thread(pc.create_index, name=IDX_FACES,
267
- dimension=IDX_FACES_DIM, # 1024-D ArcFace+AdaFace (V4)
268
- metric="cosine", spec=ServerlessSpec(cloud="aws", region="us-east-1")))
269
- indexes_created.append(IDX_FACES)
270
- if tasks: await asyncio.gather(*tasks)
271
  except Exception as e:
272
- err = str(e)
273
- clean = ("Invalid Pinecone API Key. Please check your key and try again."
274
- if "401" in err or "unauthorized" in err.lower()
275
- else f"Pinecone Error: {err}")
 
 
276
  log("ERROR", "settings.verify_keys.pinecone_fail",
277
- user_id=user_id or "anonymous", ip=ip, error=clean,
278
- duration_ms=round((time.perf_counter()-start)*1000))
279
  raise HTTPException(400, clean)
280
 
281
  log("INFO", "settings.verify_keys.success",
282
  user_id=user_id or "anonymous", mode=mode, ip=ip,
283
  indexes_created=indexes_created,
284
- duration_ms=round((time.perf_counter()-start)*1000))
285
  return {"message": "Keys verified and indexes ready!"}
286
 
287
 
288
- # ════════════════════════════════════════════════════════════════
289
  # 2. UPLOAD
290
- # ════════════════════════════════════════════════════════════════
 
291
  @app.post("/api/upload")
292
- async def upload_new_images(
293
  request: Request,
294
  files: List[UploadFile] = File(...),
295
  folder_name: str = Form(...),
296
- detect_faces: bool = Form(True),
297
  user_pinecone_key: str = Form(""),
298
  user_cloudinary_url: str = Form(""),
299
  user_id: str = Form(""),
300
  ):
301
- ip = get_ip(request)
302
- start = time.perf_counter()
303
- actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
304
- actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
305
- mode = "guest" if is_guest(actual_pc_key) else "personal"
306
-
307
- log("INFO", "upload.start",
308
- user_id=user_id or "anonymous", ip=ip, mode=mode,
309
- folder=folder_name, file_count=len(files),
310
- file_names=[f.filename for f in files][:10],
311
- detect_faces=detect_faces)
312
-
313
- if not actual_pc_key or not actual_cld_url:
314
- log("ERROR", "upload.missing_keys", user_id=user_id or "anonymous", ip=ip, mode=mode)
315
- raise HTTPException(400, "API Keys are missing. If you are a guest, the server is missing its DEFAULT_ secrets in Hugging Face.")
316
 
 
 
 
317
  if len(files) > MAX_FILES_PER_UPLOAD:
318
- raise HTTPException(400, f"Maximum {MAX_FILES_PER_UPLOAD} files per upload. Please split into smaller batches.")
 
 
 
319
 
 
 
320
  folder = standardize_category_name(folder_name)
321
  creds = get_cloudinary_creds(actual_cld_url)
322
  if not creds.get("cloud_name"):
323
- log("ERROR", "upload.bad_cloudinary_url", user_id=user_id or "anonymous", ip=ip)
324
- raise HTTPException(400, "Invalid Cloudinary URL format.")
325
-
326
- pc = _get_pinecone(actual_pc_key)
327
- idx_obj = pc.Index(IDX_OBJECTS)
328
- idx_face = pc.Index(IDX_FACES)
329
- uploaded_urls = []
330
- face_vec_total = 0
331
- object_vec_total = 0
332
-
333
- for file in files:
334
- tmp_path = f"temp_uploads/{uuid.uuid4().hex}_{sanitize_filename(file.filename)}"
335
- file_start = time.perf_counter()
336
- try:
337
- with open(tmp_path, "wb") as buf:
338
- shutil.copyfileobj(file.file, buf)
339
-
340
- res = await asyncio.to_thread(_cld_upload, tmp_path, folder, creds)
341
- image_url = res["secure_url"]
342
- uploaded_urls.append(image_url)
343
-
344
- async with _inference_sem:
345
- vectors = await ai.process_image_async(tmp_path, is_query=False, detect_faces=detect_faces)
346
-
347
- face_upserts = []
348
- object_upserts = []
349
-
350
- for v in vectors:
351
- vec_list = v["vector"].tolist() if hasattr(v["vector"], "tolist") else v["vector"]
352
- if v["type"] == "face":
353
- # ── FACE STORE: ArcFace+AdaFace 1024-D fused embedding
354
- # V4: includes face_quality + face_width_px for retrieval scoring
355
- face_upserts.append({
356
- "id": str(uuid.uuid4()),
357
- "values": vec_list,
358
- "metadata": {
359
- "image_url": image_url,
360
- "url": image_url,
361
- "folder": folder,
362
- "face_idx": v.get("face_idx", 0),
363
- "bbox": str(v.get("bbox", [])),
364
- "face_crop": v.get("face_crop", ""),
365
- "det_score": v.get("det_score", 1.0),
366
- "face_quality": v.get("face_quality", v.get("det_score", 1.0)),
367
- "face_width_px": v.get("face_width_px", 0),
368
- }
369
- })
370
- else:
371
- # ── OBJECT STORE: SigLIP+DINOv2 1536-D fused embedding
372
- object_upserts.append({
373
- "id": str(uuid.uuid4()),
374
- "values": vec_list,
375
- "metadata": {
376
- "image_url": image_url,
377
- "url": image_url,
378
- "folder": folder,
379
- }
380
- })
381
-
382
- # Always upsert to BOTH indexes:
383
- # - face index gets face embeddings (if any faces detected)
384
- # - object index ALWAYS gets full image embedding
385
- face_vec_total += len(face_upserts)
386
- object_vec_total += len(object_upserts)
387
-
388
- upsert_tasks = []
389
- if face_upserts:
390
- upsert_tasks.append(asyncio.to_thread(idx_face.upsert, vectors=face_upserts))
391
- if object_upserts:
392
- upsert_tasks.append(asyncio.to_thread(idx_obj.upsert, vectors=object_upserts))
393
- if upsert_tasks:
394
- await asyncio.gather(*upsert_tasks)
395
-
396
- log("INFO", "upload.file.success",
397
- user_id=user_id or "anonymous", ip=ip, mode=mode,
398
- filename=file.filename, folder=folder, image_url=image_url,
399
- face_vectors=len(face_upserts), obj_vectors=len(object_upserts),
400
- detect_faces=detect_faces,
401
- duration_ms=round((time.perf_counter()-file_start)*1000))
 
 
402
 
 
 
 
 
 
 
 
 
 
403
  except Exception as e:
404
- log("ERROR", "upload.file.error",
405
- user_id=user_id or "anonymous", ip=ip, mode=mode,
406
- filename=file.filename, folder=folder, error=str(e),
407
- traceback=traceback.format_exc()[-800:],
408
- duration_ms=round((time.perf_counter()-file_start)*1000))
409
- err = str(e)
410
- if "not found" in err.lower() or "404" in err:
411
- raise HTTPException(404, "Pinecone index not found. Please go to Settings and click 'Verify & Save' to recreate your indexes.")
412
- raise HTTPException(500, f"Upload processing failed: {err}")
413
- finally:
414
- if os.path.exists(tmp_path): os.remove(tmp_path)
415
 
 
416
  log("INFO", "upload.complete",
417
- user_id=user_id or "anonymous", ip=ip, mode=mode,
418
- folder=folder, files_uploaded=len(uploaded_urls),
419
- face_vectors=face_vec_total, object_vectors=object_vec_total,
420
- detect_faces=detect_faces,
421
- duration_ms=round((time.perf_counter()-start)*1000))
422
-
423
- return {"message": "Done!", "urls": uploaded_urls}
424
-
425
-
426
- # ════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
427
  # 3. SEARCH
428
- # ════════════════════════════════════════════════════════════════
 
429
  @app.post("/api/search")
430
  async def search_database(
431
  request: Request,
@@ -437,271 +407,168 @@ async def search_database(
437
  ):
438
  ip = get_ip(request)
439
  start = time.perf_counter()
440
- actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
441
- mode = "guest" if is_guest(actual_pc_key) else "personal"
442
 
443
  log("INFO", "search.start",
444
  user_id=user_id or "anonymous", ip=ip, mode=mode,
445
  filename=file.filename, detect_faces=detect_faces)
446
 
447
  if not actual_pc_key:
448
- log("ERROR", "search.missing_keys", user_id=user_id or "anonymous", ip=ip, mode=mode)
449
- raise HTTPException(400, "Pinecone Key is missing.")
450
 
451
  tmp_path = f"temp_uploads/query_{uuid.uuid4().hex}_{sanitize_filename(file.filename)}"
452
  try:
453
  with open(tmp_path, "wb") as buf:
454
  shutil.copyfileobj(file.file, buf)
455
 
456
- async with _inference_sem:
457
- vectors = await ai.process_image_async(tmp_path, is_query=True, detect_faces=detect_faces)
 
 
 
 
 
458
 
459
- inference_ms = round((time.perf_counter() - start) * 1000)
460
- lanes_used = list({v["type"] for v in vectors})
461
  log("INFO", "search.inference_done",
462
  user_id=user_id or "anonymous", ip=ip, mode=mode,
463
- vector_count=len(vectors), lanes=lanes_used, inference_ms=inference_ms)
 
464
 
465
- pc = _get_pinecone(actual_pc_key)
466
  idx_obj = pc.Index(IDX_OBJECTS)
467
  idx_face = pc.Index(IDX_FACES)
468
 
469
- # ── V4: split vectors by type ────────────────────────────
470
- face_vectors = [v for v in vectors if v["type"] == "face"]
471
- object_vectors = [v for v in vectors if v["type"] == "object"]
472
-
473
- # ════════════════════���═══════════════════════════════════
474
- # OBJECT MODE helper
475
- # Used when no faces detected or face search disabled.
476
- # ════════════════════════════════════════════════════════
477
- async def _query_object_one(vec_dict: dict):
478
- vec_list = (vec_dict["vector"].tolist()
479
- if hasattr(vec_dict["vector"], "tolist")
480
- else vec_dict["vector"])
481
- try:
482
- res = await asyncio.to_thread(
483
- idx_obj.query, vector=vec_list, top_k=10, include_metadata=True)
484
- except Exception as e:
485
- if "404" in str(e):
486
- raise HTTPException(404, "Pinecone Index not found. Go to Settings → Verify & Save.")
487
- raise e
488
- out = []
489
- for match in res.get("matches", []):
490
- if match["score"] < 0.45:
491
- continue
492
- out.append({
493
- "url": match["metadata"].get("url") or match["metadata"].get("image_url", ""),
494
- "score": round(match["score"], 4),
495
- "caption": match["metadata"].get("folder", "🎯 Visual Match"),
496
- })
497
- return out
498
-
499
  if detect_faces and face_vectors:
500
- # ════════════════════════════════════════════════════
501
- # V4 FACE MODE Multi-face merge retrieval
502
- #
503
- # For a group photo with N detected faces:
504
- # 1. Query enterprise-faces for EACH face (top_k=50)
505
- # 2. Build a global image_url → match_data map
506
- # • An image is included if ANY face matches
507
- # • Score = highest matching face score for that image
508
- # • Track WHICH face indices matched each image
509
- # 3. Group results PER query face (for UI display)
510
- # 4. Also build a "cross-face" flat list:
511
- # images that matched multiple faces rank higher
512
- #
513
- # Threshold logic:
514
- # High-quality face (det_score ≥ 0.85) → threshold 0.40
515
- # Lower-quality face → threshold 0.32
516
- # (Fused 1024-D space has different cosine distribution
517
- # than raw ArcFace 512-D — thresholds adjusted accordingly)
518
- # ════════════════════════════════════════════════════
519
-
520
- async def _query_single_face(face_vec: dict) -> dict:
521
- """
522
- Query enterprise-faces for one detected face.
523
- Returns per-face result group for UI + raw match map.
524
- """
525
- vec_list = (face_vec["vector"].tolist()
526
- if hasattr(face_vec["vector"], "tolist")
527
- else face_vec["vector"])
528
-
529
- # Adaptive threshold: high-quality → stricter
530
- det_score = face_vec.get("det_score", 1.0)
531
- threshold = FACE_THRESHOLD_HIGH if det_score >= 0.85 else FACE_THRESHOLD_LOW
532
-
533
  try:
534
- face_res = await asyncio.to_thread(
535
- idx_face.query,
536
- vector=vec_list,
537
- top_k=FACE_TOP_K_FETCH,
538
- include_metadata=True,
539
- )
540
  except Exception as e:
541
  if "404" in str(e):
542
- raise HTTPException(404, "Pinecone index not found. Go to Settings → Verify & Save.")
543
- raise e
544
-
545
- # Collect matches — keep BEST score per image_url
546
- # (multiple face vectors stored per image during upload,
547
- # we only want the best matching one per image)
548
- image_map = {} # image_url → best match data
549
- for match in face_res.get("matches", []):
550
- raw = match["score"]
551
- if raw < threshold:
552
- continue
553
- url = (match["metadata"].get("url") or
554
- match["metadata"].get("image_url", ""))
555
- if not url:
556
- continue
557
- if url not in image_map or raw > image_map[url]["raw_score"]:
558
- image_map[url] = {
559
- "raw_score": raw,
560
- "face_crop": match["metadata"].get("face_crop", ""),
561
- "folder": match["metadata"].get("folder", ""),
562
- "face_quality": match["metadata"].get("face_quality", 1.0),
563
- "face_width_px": match["metadata"].get("face_width_px", 0),
564
- }
565
-
566
- # Remap raw cosine → UI score (75%–99%)
567
- # Range is now 0.32–1.0 (wider than old 0.35–1.0)
568
- def _ui_score(raw: float) -> float:
569
- lo, hi = FACE_THRESHOLD_LOW, 1.0
570
- return round(min(0.99, 0.75 + ((raw - lo) / (hi - lo)) * 0.24), 4)
571
-
572
- matches = [
573
- {
574
- "url": url,
575
- "score": _ui_score(d["raw_score"]),
576
- "raw_score": round(d["raw_score"], 4),
577
- "face_crop": d["face_crop"],
578
- "folder": d["folder"],
579
- "caption": "👤 Verified Identity",
580
- }
581
- for url, d in image_map.items()
582
- ]
583
- matches = sorted(matches, key=lambda x: x["score"], reverse=True)[:15]
584
-
585
  return {
586
- "query_face_idx": face_vec.get("face_idx", 0),
587
- "query_face_crop": face_vec.get("face_crop", ""),
 
588
  "det_score": det_score,
589
- "face_width_px": face_vec.get("face_width_px", 0),
590
- "matches": matches,
591
- "_image_map": image_map, # used for cross-face merge below
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  }
593
 
594
- # Query all faces in parallel
595
- raw_groups = await asyncio.gather(
596
- *[_query_single_face(fv) for fv in face_vectors])
597
-
598
- # ── Cross-face merge ────────────────────────────────
599
- # Build a global image → {best_score, matched_face_indices}
600
- # An image appearing for multiple faces gets a boost:
601
- # final_score = best_face_score * (1 + 0.05 * extra_face_count)
602
- # This makes images with multiple searched people rank higher.
603
- global_image_map = {} # url → {score, matched_faces, face_crop, folder}
604
- for gi, group in enumerate(raw_groups):
605
- for url, d in group["_image_map"].items():
606
- raw = d["raw_score"]
607
- if url not in global_image_map:
608
- global_image_map[url] = {
609
- "raw_score": raw,
610
- "face_crop": d["face_crop"],
611
- "folder": d["folder"],
612
- "matched_faces": [gi],
613
- }
614
- else:
615
- existing = global_image_map[url]
616
- existing["matched_faces"].append(gi)
617
- if raw > existing["raw_score"]:
618
- existing["raw_score"] = raw
619
- existing["face_crop"] = d["face_crop"]
620
-
621
- # Apply multi-face boost and build flat merged list
622
- def _boosted_ui_score(raw: float, n_faces: int) -> float:
623
- lo = FACE_THRESHOLD_LOW
624
- base = 0.75 + ((raw - lo) / (1.0 - lo)) * 0.24
625
- boosted = base * (1.0 + 0.05 * (n_faces - 1))
626
- return round(min(0.99, boosted), 4)
627
-
628
- merged_results = []
629
- for url, d in global_image_map.items():
630
- n = len(d["matched_faces"])
631
- merged_results.append({
632
- "url": url,
633
- "score": _boosted_ui_score(d["raw_score"], n),
634
- "raw_score": round(d["raw_score"], 4),
635
- "face_crop": d["face_crop"],
636
- "folder": d["folder"],
637
- "matched_faces": d["matched_faces"],
638
- "caption": (f"👥 {n} faces matched" if n > 1
639
- else "👤 Verified Identity"),
640
- })
641
- merged_results = sorted(
642
- merged_results, key=lambda x: x["score"], reverse=True)[:20]
643
-
644
- # Clean per-group results (remove internal _image_map)
645
- face_groups = []
646
- for g in raw_groups:
647
- clean = {k: v for k, v in g.items() if k != "_image_map"}
648
- if clean["matches"]:
649
- face_groups.append(clean)
650
-
651
- duration_ms = round((time.perf_counter() - start) * 1000)
652
- total_matches = len(merged_results)
653
  log("INFO", "search.complete",
654
  user_id=user_id or "anonymous", ip=ip, mode=mode,
655
- lanes=["face"], detect_faces=detect_faces,
656
- face_groups=len(face_groups),
657
- merged_results=total_matches,
658
- top_score=merged_results[0]["score"] if merged_results else 0,
659
  duration_ms=duration_ms)
660
 
661
  return {
662
  "mode": "face",
663
- "face_groups": face_groups, # per-face results for UI tabs
664
- "results": merged_results, # V4: flat merged cross-face list
 
665
  }
666
 
667
  else:
668
- # ════════════════════════════════════════════════════
669
- # OBJECT MODE flat ranked results from object index
670
- # ════════════════════════════════════════════════════
671
- nested = await asyncio.gather(
672
- *[_query_object_one(v) for v in vectors])
673
- all_results = [r for sub in nested for r in sub]
674
- seen = {}
675
- for r in all_results:
676
- url = r["url"]
677
- if url not in seen or r["score"] > seen[url]["score"]:
678
- seen[url] = r
679
- final = sorted(seen.values(), key=lambda x: x["score"], reverse=True)[:10]
 
 
 
 
 
 
680
 
681
  duration_ms = round((time.perf_counter() - start) * 1000)
682
  log("INFO", "search.complete",
683
  user_id=user_id or "anonymous", ip=ip, mode=mode,
684
- lanes=lanes_used, detect_faces=detect_faces,
685
- results_count=len(final),
686
  top_score=final[0]["score"] if final else 0,
687
  duration_ms=duration_ms)
688
 
689
  return {"mode": "object", "results": final, "face_groups": []}
690
 
691
- except HTTPException: raise
 
692
  except Exception as e:
693
  log("ERROR", "search.error",
694
  user_id=user_id or "anonymous", ip=ip, mode=mode,
695
  error=str(e), traceback=traceback.format_exc()[-800:],
696
- duration_ms=round((time.perf_counter()-start)*1000))
697
  raise HTTPException(500, str(e))
698
  finally:
699
- if os.path.exists(tmp_path): os.remove(tmp_path)
 
700
 
701
 
702
- # ════════════════════════════════════════════════════════════════
703
  # 4. CATEGORIES
704
- # ════════════════════════════════════════════════════════════════
 
705
  @app.post("/api/categories")
706
  async def get_categories(
707
  request: Request,
@@ -709,64 +576,27 @@ async def get_categories(
709
  user_id: str = Form(""),
710
  ):
711
  ip = get_ip(request)
712
- actual_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
713
- if not actual_url: return {"categories": []}
 
714
  try:
715
  creds = get_cloudinary_creds(actual_url)
716
- if not creds.get("cloud_name"): return {"categories": []}
717
- result = await asyncio.to_thread(_cld_root_folders, creds)
 
718
  categories = [f["name"] for f in result.get("folders", [])]
719
  log("INFO", "categories.fetched",
720
- user_id=user_id or "anonymous", ip=ip, category_count=len(categories))
721
  return {"categories": categories}
722
  except Exception as e:
723
- log("ERROR", "categories.error", user_id=user_id or "anonymous", ip=ip, error=str(e))
 
724
  return {"categories": []}
725
 
726
 
727
- @app.get("/")
728
- async def root():
729
- return {"status": "ok"}
730
-
731
-
732
- @app.get("/api/health")
733
- async def health():
734
- return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
735
-
736
-
737
- # ════════════════════════════════════════════════════════════════
738
  # 5. LIST FOLDER IMAGES
739
- # ════════════════════════════════════════════════════════════════
740
- def _cld_list_folder_images(folder: str, creds: dict, next_cursor: str = None, max_results: int = 100):
741
- kwargs = dict(type="upload", prefix=f"{folder}/", max_results=max_results,
742
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
743
- if next_cursor: kwargs["next_cursor"] = next_cursor
744
- return cloudinary.api.resources(**kwargs)
745
-
746
-
747
- def _cld_thumb_url(secure_url: str, cloud_name: str) -> str:
748
- """
749
- Convert full-resolution Cloudinary URL to a small thumbnail URL.
750
- Inserts Cloudinary transformation: 400×400 fill, auto quality, auto format.
751
- Example:
752
- https://res.cloudinary.com/demo/image/upload/v123/folder/img.jpg
753
-
754
- https://res.cloudinary.com/demo/image/upload/w_400,h_400,c_fill,q_auto,f_auto/v123/folder/img.jpg
755
- """
756
- try:
757
- marker = f"/image/upload/"
758
- idx = secure_url.find(marker)
759
- if idx == -1:
760
- return secure_url # can't transform — return original
761
- base = secure_url[:idx + len(marker)]
762
- rest = secure_url[idx + len(marker):]
763
- # Skip existing transformation block if present
764
- if rest.startswith("w_") or rest.startswith("h_") or rest.startswith("c_") or rest.startswith("q_"):
765
- return secure_url # already transformed
766
- return f"{base}w_400,h_400,c_fill,q_auto,f_auto/{rest}"
767
- except Exception:
768
- return secure_url
769
-
770
 
771
  @app.post("/api/cloudinary/folder-images")
772
  async def list_folder_images(
@@ -778,49 +608,35 @@ async def list_folder_images(
778
  page_size: int = Form(100),
779
  ):
780
  ip = get_ip(request)
781
- actual_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
782
  creds = get_cloudinary_creds(actual_url)
783
- if not creds.get("cloud_name"): raise HTTPException(400, "Invalid Cloudinary URL.")
 
784
 
785
- # Single page fetch — frontend drives pagination via next_cursor
786
- result = await asyncio.to_thread(
787
- _cld_list_folder_images, folder_name, creds,
788
- next_cursor or None, min(page_size, 100)
789
  )
790
- images = []
791
- for r in result.get("resources", []):
792
- full_url = r["secure_url"]
793
- thumb_url = _cld_thumb_url(full_url, creds["cloud_name"])
794
- images.append({
795
- "url": full_url, # full-res for lightbox / download
796
- "thumb_url": thumb_url, # 400×400 thumbnail for grid display
797
  "public_id": r["public_id"],
798
- })
 
 
 
799
 
800
- response_cursor = result.get("next_cursor") or ""
801
  log("INFO", "explorer.folder_opened",
802
  user_id=user_id or "anonymous", ip=ip,
803
- folder_name=folder_name, image_count=len(images),
804
- has_more=bool(response_cursor))
805
- return {"images": images, "count": len(images), "next_cursor": response_cursor}
806
 
807
 
808
- # ════════════════════════════════════════════════════════════════
809
  # 6. DELETE SINGLE IMAGE
810
- # ════════════════════════════════════════════════════════════════
811
- def url_to_public_id(image_url: str, cloud_name: str) -> str:
812
- try:
813
- path = urlparse(image_url).path
814
- parts = path.split("/")
815
- upload_idx = parts.index("upload")
816
- after = parts[upload_idx + 1:]
817
- if after and after[0].startswith("v") and after[0][1:].isdigit(): after = after[1:]
818
- return "/".join(after).rsplit(".", 1)[0]
819
- except Exception: return ""
820
-
821
- def _cld_delete_resource(public_id: str, creds: dict):
822
- return cloudinary.uploader.destroy(public_id,
823
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
824
 
825
  @app.post("/api/delete-image")
826
  async def delete_image(
@@ -832,66 +648,38 @@ async def delete_image(
832
  user_id: str = Form(""),
833
  ):
834
  ip = get_ip(request)
835
- actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
836
- actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
837
  creds = get_cloudinary_creds(actual_cld_url)
838
- if not creds.get("cloud_name"): raise HTTPException(400, "Invalid Cloudinary URL.")
839
- pid = public_id or url_to_public_id(image_url, creds["cloud_name"])
840
- if not pid: raise HTTPException(400, "Could not determine public_id.")
841
- await asyncio.to_thread(_cld_delete_resource, pid, creds)
 
 
 
 
 
842
  if actual_pc_key and image_url:
843
  try:
844
- pc = _get_pinecone(actual_pc_key)
845
  for idx_name in [IDX_OBJECTS, IDX_FACES]:
846
- await asyncio.to_thread(pc.Index(idx_name).delete, filter={"url": {"$eq": image_url}})
 
 
 
847
  except Exception as e:
848
  _log_fn("WARNING", f"Pinecone delete warning: {e}")
 
849
  log("INFO", "explorer.image_deleted",
850
  user_id=user_id or "anonymous", ip=ip,
851
  image_url=image_url, public_id=pid)
852
  return {"message": "Image deleted successfully."}
853
 
854
 
855
- # ════════════════════════════════════════════════════════════════
856
  # 7. DELETE ENTIRE FOLDER
857
- # ════════════════════════════════════════════════════════════════
858
- def _cld_delete_folder(folder: str, creds: dict):
859
- return cloudinary.api.delete_resources_by_prefix(f"{folder}/",
860
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
861
-
862
- def _cld_remove_folder(folder: str, creds: dict):
863
- try:
864
- return cloudinary.api.delete_folder(folder,
865
- api_key=creds["api_key"], api_secret=creds["api_secret"], cloud_name=creds["cloud_name"])
866
- except Exception: pass
867
-
868
- def _cld_delete_all_paginated(creds: dict):
869
- """Delete ALL Cloudinary resources in batches of 100 until none left."""
870
- deleted = 0
871
- while True:
872
- try:
873
- res = cloudinary.api.resources(
874
- type="upload", max_results=100,
875
- api_key=creds["api_key"], api_secret=creds["api_secret"],
876
- cloud_name=creds["cloud_name"],
877
- )
878
- resources = res.get("resources", [])
879
- if not resources:
880
- break
881
- public_ids = [r["public_id"] for r in resources]
882
- cloudinary.api.delete_resources(
883
- public_ids,
884
- api_key=creds["api_key"], api_secret=creds["api_secret"],
885
- cloud_name=creds["cloud_name"],
886
- )
887
- deleted += len(public_ids)
888
- print(f"🗑️ Deleted {deleted} resources so far...")
889
- if not res.get("next_cursor"):
890
- break
891
- except Exception as e:
892
- print(f"Cloudinary batch delete error: {e}")
893
- break
894
- return deleted
895
 
896
  @app.post("/api/delete-folder")
897
  async def delete_folder(
@@ -902,42 +690,60 @@ async def delete_folder(
902
  user_id: str = Form(""),
903
  ):
904
  ip = get_ip(request)
905
- actual_pc_key = user_pinecone_key or os.getenv("DEFAULT_PINECONE_KEY", "")
906
- actual_cld_url = user_cloudinary_url or os.getenv("DEFAULT_CLOUDINARY_URL", "")
907
  creds = get_cloudinary_creds(actual_cld_url)
908
- if not creds.get("cloud_name"): raise HTTPException(400, "Invalid Cloudinary URL.")
909
- all_images, next_cursor = [], None
 
 
 
910
  while True:
911
- result = await asyncio.to_thread(_cld_list_folder_images, folder_name, creds, next_cursor)
 
912
  all_images.extend(result.get("resources", []))
913
- next_cursor = result.get("next_cursor")
914
- if not next_cursor: break
915
- await asyncio.to_thread(_cld_delete_folder, folder_name, creds)
916
- await asyncio.to_thread(_cld_remove_folder, folder_name, creds)
 
 
 
 
917
  if actual_pc_key:
918
  try:
919
- pc = _get_pinecone(actual_pc_key)
920
  for idx_name in [IDX_OBJECTS, IDX_FACES]:
921
  idx = pc.Index(idx_name)
922
  try:
923
- await asyncio.to_thread(idx.delete, filter={"folder": {"$eq": folder_name}})
 
924
  except Exception:
 
925
  for img in all_images:
926
- try:
927
- if img.get("secure_url"):
928
- await asyncio.to_thread(idx.delete, filter={"url": {"$eq": img["secure_url"]}})
929
- except Exception: pass
 
 
 
930
  except Exception as e:
931
  _log_fn("WARNING", f"Pinecone folder delete warning: {e}")
 
932
  log("INFO", "explorer.folder_deleted",
933
  user_id=user_id or "anonymous", ip=ip,
934
- folder_name=folder_name, deleted_count=len(all_images))
935
- return {"message": f"Folder '{folder_name}' and all its contents deleted.", "deleted_count": len(all_images)}
 
 
 
 
936
 
 
 
 
937
 
938
- # ════════════════════════════════════════════════════════════════
939
- # 8. RESET DATABASE ⚠️ DESTRUCTIVE — triple-logged
940
- # ════════════════════════════════════════════════════════════════
941
  @app.post("/api/reset-database")
942
  async def reset_database(
943
  request: Request,
@@ -947,30 +753,30 @@ async def reset_database(
947
  ):
948
  ip = get_ip(request)
949
  start = time.perf_counter()
950
-
951
  log("WARNING", "danger.reset_database.attempt",
952
  user_id=user_id or "anonymous", ip=ip)
953
 
954
- if _is_default_key(user_pinecone_key, DEFAULT_PC_KEY) or _is_default_key(user_cloudinary_url, DEFAULT_CLD_URL):
955
- log("WARNING", "danger.reset_database.blocked_shared_db",
 
956
  user_id=user_id or "anonymous", ip=ip)
957
  raise HTTPException(403, "Reset is not allowed on the shared demo database.")
958
 
959
  creds = get_cloudinary_creds(user_cloudinary_url)
960
- if not creds.get("cloud_name"): raise HTTPException(400, "Invalid Cloudinary URL.")
 
961
 
962
- # ── Cloudinary: paginated delete ALL resources then folders ────
963
  try:
964
- deleted = await asyncio.to_thread(_cld_delete_all_paginated, creds)
965
  _log_fn("INFO", f"Cloudinary: deleted {deleted} resources")
966
  except Exception as e:
967
  _log_fn("WARNING", f"Cloudinary wipe: {e}")
968
 
969
  try:
970
- folders_res = await asyncio.to_thread(_cld_root_folders, creds)
971
- # Delete all folders in parallel
972
  folder_tasks = [
973
- asyncio.to_thread(_cld_remove_folder, f["name"], creds)
974
  for f in folders_res.get("folders", [])
975
  ]
976
  if folder_tasks:
@@ -978,23 +784,10 @@ async def reset_database(
978
  except Exception as e:
979
  _log_fn("WARNING", f"Cloudinary folder cleanup: {e}")
980
 
981
- # ── Pinecone: delete both indexes + recreate ─────────────────
982
  try:
983
- pc = _get_pinecone(user_pinecone_key)
984
- existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
985
- tasks = []
986
- if IDX_OBJECTS in existing: tasks.append(asyncio.to_thread(pc.delete_index, IDX_OBJECTS))
987
- if IDX_FACES in existing: tasks.append(asyncio.to_thread(pc.delete_index, IDX_FACES))
988
- if tasks: await asyncio.gather(*tasks)
989
- await asyncio.sleep(3) # wait for Pinecone to fully delete
990
- await asyncio.gather(
991
- asyncio.to_thread(pc.create_index, name=IDX_OBJECTS,
992
- dimension=IDX_OBJECTS_DIM, metric="cosine", # 1536-D
993
- spec=ServerlessSpec(cloud="aws", region="us-east-1")),
994
- asyncio.to_thread(pc.create_index, name=IDX_FACES,
995
- dimension=IDX_FACES_DIM, metric="cosine", # 1024-D V4
996
- spec=ServerlessSpec(cloud="aws", region="us-east-1")),
997
- )
998
  except Exception as e:
999
  log("ERROR", "danger.reset_database.pinecone_error",
1000
  user_id=user_id or "anonymous", ip=ip, error=str(e))
@@ -1002,13 +795,14 @@ async def reset_database(
1002
 
1003
  log("WARNING", "danger.reset_database.complete",
1004
  user_id=user_id or "anonymous", ip=ip,
1005
- duration_ms=round((time.perf_counter()-start)*1000))
1006
  return {"message": "Database reset complete. All data wiped and indexes recreated."}
1007
 
1008
 
1009
- # ════════════════════════════════════════════════════════════════
1010
- # 9. DELETE ACCOUNT ⚠️ DESTRUCTIVE — triple-logged
1011
- # ════════════════════════════════════════════════════════════════
 
1012
  @app.post("/api/delete-account")
1013
  async def delete_account(
1014
  request: Request,
@@ -1018,47 +812,48 @@ async def delete_account(
1018
  ):
1019
  ip = get_ip(request)
1020
  start = time.perf_counter()
1021
-
1022
  log("WARNING", "danger.delete_account.attempt",
1023
  user_id=user_id or "anonymous", ip=ip)
1024
 
1025
- if _is_default_key(user_pinecone_key, DEFAULT_PC_KEY) or _is_default_key(user_cloudinary_url, DEFAULT_CLD_URL):
1026
- log("WARNING", "danger.delete_account.blocked_shared_db",
 
1027
  user_id=user_id or "anonymous", ip=ip)
1028
  raise HTTPException(403, "Account deletion is not allowed on the shared demo database.")
1029
 
1030
  creds = get_cloudinary_creds(user_cloudinary_url)
1031
 
1032
- # ── Cloudinary: paginated delete ALL resources then folders ────
1033
  try:
1034
- deleted = await asyncio.to_thread(_cld_delete_all_paginated, creds)
1035
- _log_fn("INFO", f"Account delete Cloudinary: {deleted} resources removed")
1036
  except Exception as e:
1037
  _log_fn("WARNING", f"Account delete Cloudinary: {e}")
1038
 
1039
  try:
1040
- folders_res = await asyncio.to_thread(_cld_root_folders, creds)
1041
  folder_tasks = [
1042
- asyncio.to_thread(_cld_remove_folder, f["name"], creds)
1043
  for f in folders_res.get("folders", [])
1044
  ]
1045
  if folder_tasks:
1046
  await asyncio.gather(*folder_tasks, return_exceptions=True)
1047
  except Exception as e:
1048
- _log_fn("WARNING", f"Account delete Cloudinary folders: {e}")
1049
 
1050
- # ── Pinecone: delete both indexes ────────────────────────────
1051
  try:
1052
- pc = _get_pinecone(user_pinecone_key)
1053
  existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
1054
- tasks = []
1055
- if IDX_OBJECTS in existing: tasks.append(asyncio.to_thread(pc.delete_index, IDX_OBJECTS))
1056
- if IDX_FACES in existing: tasks.append(asyncio.to_thread(pc.delete_index, IDX_FACES))
1057
- if tasks: await asyncio.gather(*tasks)
 
 
 
1058
  except Exception as e:
1059
  _log_fn("WARNING", f"Account delete Pinecone: {e}")
1060
 
1061
  log("WARNING", "danger.delete_account.complete",
1062
  user_id=user_id or "anonymous", ip=ip,
1063
- duration_ms=round((time.perf_counter()-start)*1000))
1064
  return {"message": "Account data deleted. Sign out initiated."}
 
1
+ """
2
+ main.py — FastAPI application entry point.
3
+
4
+ Responsibilities:
5
+ - Server lifecycle (model loading, semaphore, graceful shutdown)
6
+ - Route definitions (upload, search, categories, CRUD, reset)
7
+ - Async orchestration (Cloudinary + AI concurrently, batch Pinecone upserts)
8
+ - Logging to console and Supabase (fire-and-forget)
9
+
10
+ All constants live in src/config.py.
11
+ All helper logic lives in src/db.py and src/utils.py.
12
+ All AI inference lives in src/models.py.
13
+ """
14
+
15
+ # ── Standard library (no duplicates) ────────────────────────────────
16
  import asyncio
17
+ import io
18
+ import json
19
  import os
20
  import shutil
 
 
21
  import time
 
22
  import traceback
23
+ import uuid
24
+ from collections import OrderedDict
25
+ from contextlib import asynccontextmanager
26
  from datetime import datetime, timezone
 
27
  from typing import List
 
 
28
 
29
+ # ── Third-party ──────────────────────────────────────────────────────
30
+ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
31
  from fastapi.middleware.cors import CORSMiddleware
32
+ from pinecone import ServerlessSpec
33
+
34
+ # ── Internal ─────────────────────────────────────────────────────────
35
+ from src.config import (
36
+ DEFAULT_PINECONE_KEY, DEFAULT_CLOUDINARY_URL,
37
+ SUPABASE_URL, SUPABASE_SERVICE_KEY,
38
+ MAX_CONCURRENT_INFERENCES, MAX_FILES_PER_UPLOAD,
39
+ IDX_FACES, IDX_OBJECTS, IDX_FACES_DIM, IDX_OBJECTS_DIM,
40
+ )
41
+ from src.db import (
42
+ pinecone_pool,
43
+ cld_upload, cld_ping, cld_root_folders,
44
+ cld_list_folder_images, cld_delete_resource,
45
+ cld_delete_folder_resources, cld_remove_folder, cld_delete_all_paginated,
46
+ ensure_indexes, delete_and_recreate_indexes,
47
+ search_faces, search_objects, merge_face_results, merge_object_results,
48
+ )
49
+ from src.utils import (
50
+ get_ip, get_cloudinary_creds, is_default_key,
51
+ sanitize_filename, standardize_category_name,
52
+ face_ui_score, cld_thumb_url, url_to_public_id,
53
+ to_list,
54
+ )
55
+
56
+ # ── AI model (loaded once at startup) ───────────────────────────────
57
+ _ai = None
58
+ _sem: asyncio.Semaphore # limits concurrent AI inference calls
59
+
60
+
61
+ # ════════════════════════════════════════════════════════════════════
62
+ # LOGGING — console + optional Supabase (fire-and-forget)
63
+ # ════════════════════════════════════════════════════════════════════
64
 
 
65
  try:
66
  from loguru import logger as _loguru
67
  _loguru.remove()
 
74
  except ImportError:
75
  import logging as _logging
76
  _logging.basicConfig(level=_logging.INFO)
77
+ _stdlib = _logging.getLogger("vsl")
78
+ def _log_fn(level, msg):
79
+ _stdlib.log(getattr(_logging, level, 20), msg)
80
+
81
+
82
+ async def _supabase_log(level: str, event: str, data: dict):
83
+ """Fire-and-forget insert into Supabase app_logs table. Never raises."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  if not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
85
  return
86
  try:
 
93
  "mode": str(data.get("mode", "")),
94
  "page": str(data.get("page", "")),
95
  "duration_ms": int(data["duration_ms"]) if "duration_ms" in data else None,
96
+ "error": str(data["error"]) if "error" in data else None,
97
  "data": data,
98
  }
99
  headers = {
 
106
  async with s.post(
107
  f"{SUPABASE_URL}/rest/v1/app_logs",
108
  json=row, headers=headers,
109
+ timeout=aiohttp.ClientTimeout(total=5),
110
  ) as r:
111
  if r.status not in (200, 201):
112
  body = await r.text()
113
+ _log_fn("WARNING", f"Supabase log failed {r.status}: {body[:200]}")
114
  except Exception as exc:
115
  _log_fn("DEBUG", f"Supabase log push skipped: {exc}")
116
 
117
+
118
  def log(level: str, event: str, **data):
119
+ """Log to console and Supabase (background task, never blocks the request)."""
120
+ _log_fn(level.upper(), f"[{event}] {json.dumps(data, default=str)}")
 
 
 
 
121
  try:
122
  loop = asyncio.get_event_loop()
123
  if loop.is_running():
124
+ asyncio.create_task(_supabase_log(level, event, data))
125
  except Exception:
126
  pass
127
 
128
 
129
+ # ════════════════════════════════════════════════════════════════════
130
+ # APPLICATION LIFECYCLE
131
+ # ════════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  @asynccontextmanager
134
  async def lifespan(app: FastAPI):
135
+ """
136
+ Load all AI models before accepting requests.
137
+ AIModelManager.__init__ is synchronous and CPU/GPU-heavy, so it runs
138
+ in a thread-pool executor to avoid blocking the event loop at startup.
139
+ """
140
+ global _ai, _sem
141
  from src.models import AIModelManager
142
  log("INFO", "server.startup", message="Loading AI models...")
143
  loop = asyncio.get_event_loop()
144
+ _ai = await loop.run_in_executor(None, AIModelManager)
145
+ _sem = asyncio.Semaphore(MAX_CONCURRENT_INFERENCES)
146
  log("INFO", "server.ready", message="All models loaded. API ready.")
147
  yield
148
  log("INFO", "server.shutdown", message="API shutting down.")
149
 
150
+
151
  app = FastAPI(lifespan=lifespan)
152
+ app.add_middleware(
153
+ CORSMiddleware,
154
  allow_origins=["*"], allow_credentials=True,
155
+ allow_methods=["*"], allow_headers=["*"],
156
+ )
157
  os.makedirs("temp_uploads", exist_ok=True)
158
 
159
 
160
+ # ════════════════════════════════════════════════════════════════════
161
+ # HEALTH & STATUS
162
+ # ════════════════════════════════════════════════════════════════════
163
+
164
+ @app.get("/")
165
+ async def root():
166
+ return {"status": "ok"}
167
+
168
+
169
+ @app.get("/api/health")
170
+ async def health():
171
+ return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
172
+
173
+
174
+ # ════════════════════════════════════════════════════════════════════
175
+ # FRONTEND EVENT LOG
176
+ # ════════════════════════════════════════════════════════════════════
177
+
178
  @app.post("/api/log")
179
  async def frontend_log(
180
  request: Request,
181
+ event: str = Form(...),
182
  user_id: str = Form(""),
183
  page: str = Form(""),
184
+ metadata: str = Form("{}"),
185
  ):
186
+ """Receives client-side events (page visits, tab switches, errors) for analytics."""
187
  ip = get_ip(request)
188
  try:
189
  meta = json.loads(metadata) if metadata else {}
190
  except Exception:
191
  meta = {}
192
  log("INFO", f"frontend.{event}",
193
+ user_id=user_id or "anonymous",
194
+ page=page, ip=ip,
195
+ ua=request.headers.get("User-Agent", "")[:120],
196
+ **meta)
 
 
197
  return {"ok": True}
198
 
199
 
200
+ # ════════════════════════════════════════════════════════════════════
201
  # 1. VERIFY KEYS & AUTO-BUILD INDEXES
202
+ # ════════════════════════════════════════════════════════════════════
203
+
204
  @app.post("/api/verify-keys")
205
  async def verify_keys(
206
  request: Request,
 
209
  user_id: str = Form(""),
210
  ):
211
  ip = get_ip(request)
212
+ mode = "guest" if is_default_key(pinecone_key, DEFAULT_PINECONE_KEY) else "personal"
213
  start = time.perf_counter()
214
+ log("INFO", "settings.verify_keys.start", user_id=user_id or "anonymous", mode=mode, ip=ip)
 
 
215
 
216
  if cloudinary_url:
217
+ creds = get_cloudinary_creds(cloudinary_url)
218
+ if not creds.get("cloud_name"):
219
+ raise HTTPException(400, "Invalid Cloudinary Environment URL.")
220
  try:
221
+ await asyncio.to_thread(cld_ping, creds)
 
 
 
222
  except Exception as e:
223
  log("ERROR", "settings.verify_keys.cloudinary_fail",
224
+ user_id=user_id or "anonymous", ip=ip, error=str(e))
 
225
  raise HTTPException(400, "Invalid Cloudinary Environment URL.")
226
 
227
  indexes_created = []
228
  if pinecone_key:
229
  try:
230
+ pc = pinecone_pool.get(pinecone_key)
231
+ created = await asyncio.to_thread(ensure_indexes, pc)
232
+ indexes_created = created
 
 
 
 
 
 
 
 
 
 
 
233
  except Exception as e:
234
+ err = str(e)
235
+ clean = (
236
+ "Invalid Pinecone API Key."
237
+ if "401" in err or "unauthorized" in err.lower()
238
+ else f"Pinecone Error: {err}"
239
+ )
240
  log("ERROR", "settings.verify_keys.pinecone_fail",
241
+ user_id=user_id or "anonymous", ip=ip, error=clean)
 
242
  raise HTTPException(400, clean)
243
 
244
  log("INFO", "settings.verify_keys.success",
245
  user_id=user_id or "anonymous", mode=mode, ip=ip,
246
  indexes_created=indexes_created,
247
+ duration_ms=round((time.perf_counter() - start) * 1000))
248
  return {"message": "Keys verified and indexes ready!"}
249
 
250
 
251
+ # ════════════════════════════════════════════════════════════════════
252
  # 2. UPLOAD
253
+ # ════════════════════════════════════════════════════════════════════
254
+
255
  @app.post("/api/upload")
256
+ async def upload_images(
257
  request: Request,
258
  files: List[UploadFile] = File(...),
259
  folder_name: str = Form(...),
260
+ detect_faces: bool = Form(True), # configurable, not hardcoded
261
  user_pinecone_key: str = Form(""),
262
  user_cloudinary_url: str = Form(""),
263
  user_id: str = Form(""),
264
  ):
265
+ ip = get_ip(request)
266
+ start = time.perf_counter()
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
+ # ── Enforce file count limit ─────────────────────────────────────
269
+ # Each file spawns concurrent Cloudinary + AI tasks.
270
+ # Uncapped batches can exhaust RAM / GPU memory.
271
  if len(files) > MAX_FILES_PER_UPLOAD:
272
+ raise HTTPException(
273
+ 400,
274
+ f"Too many files. Maximum {MAX_FILES_PER_UPLOAD} per request, got {len(files)}."
275
+ )
276
 
277
+ actual_pc_key = user_pinecone_key or DEFAULT_PINECONE_KEY
278
+ actual_cld_url = user_cloudinary_url or DEFAULT_CLOUDINARY_URL
279
  folder = standardize_category_name(folder_name)
280
  creds = get_cloudinary_creds(actual_cld_url)
281
  if not creds.get("cloud_name"):
282
+ raise HTTPException(400, "Invalid Cloudinary URL.")
283
+
284
+ pc = pinecone_pool.get(actual_pc_key)
285
+ idx_obj = pc.Index(IDX_OBJECTS)
286
+ idx_face = pc.Index(IDX_FACES)
287
+
288
+ all_face_upserts: list[dict] = []
289
+ all_object_upserts: list[dict] = []
290
+ uploaded_urls: list[str] = []
291
+
292
+ async def _process_file(file: UploadFile) -> tuple[str, str, list]:
293
+ """
294
+ For a single file:
295
+ 1. Read bytes into memory
296
+ 2. Write to temp path (YOLO/InsightFace need a file path)
297
+ 3. Run Cloudinary upload and AI inference CONCURRENTLY
298
+ 4. Clean up temp file in finally block
299
+
300
+ Cloudinary gets an in-memory BytesIO — no disk I/O needed.
301
+ AI gets the temp file path — required by ONNX/YOLO loaders.
302
+ """
303
+ file_bytes = await file.read()
304
+ file_id = uuid.uuid4().hex
305
+ tmp_path = f"temp_uploads/upload_{file_id}_{sanitize_filename(file.filename)}"
306
+
307
+ with open(tmp_path, "wb") as f:
308
+ f.write(file_bytes)
309
+
310
+ async def _run_ai():
311
+ try:
312
+ async with _sem:
313
+ return await _ai.process_image_async(tmp_path, detect_faces=detect_faces)
314
+ finally:
315
+ if os.path.exists(tmp_path):
316
+ os.remove(tmp_path)
317
+
318
+ cld_task = asyncio.to_thread(cld_upload, io.BytesIO(file_bytes), folder, creds)
319
+ ai_task = _run_ai()
320
+ cld_res, vectors = await asyncio.gather(cld_task, ai_task)
321
+ return file_id, cld_res["secure_url"], vectors
322
+
323
+ # Run all files concurrently
324
+ results = await asyncio.gather(*[_process_file(f) for f in files])
325
+
326
+ # ── Build Pinecone upsert payloads ───────────────────────────────
327
+ # Metadata schema (single `url` key — no duplication):
328
+ # url : Cloudinary secure_url for retrieval
329
+ # folder : category/folder name for filtering
330
+ # face_crop : base64 JPEG thumbnail (faces only, for UI display)
331
+ # det_score : InsightFace detection confidence (faces only)
332
+ # face_width_px : face width in pixels (faces only, for threshold selection)
333
+ for file_id, image_url, vectors in results:
334
+ uploaded_urls.append(image_url)
335
+
336
+ for i, v in enumerate(vectors):
337
+ vector_id = f"{file_id}_{i}"
338
+ lane = v["type"]
339
+
340
+ if lane == "face":
341
+ metadata = {
342
+ "url": image_url,
343
+ "folder": folder,
344
+ "face_crop": v.get("face_crop", ""),
345
+ "det_score": float(v.get("det_score", 1.0)),
346
+ "face_width_px": int(v.get("face_width_px", 0)),
347
+ }
348
+ all_face_upserts.append({
349
+ "id": vector_id,
350
+ "values": to_list(v["vector"]),
351
+ "metadata": metadata,
352
+ })
353
+ else:
354
+ metadata = {
355
+ "url": image_url,
356
+ "folder": folder,
357
+ }
358
+ all_object_upserts.append({
359
+ "id": vector_id,
360
+ "values": to_list(v["vector"]),
361
+ "metadata": metadata,
362
+ })
363
 
364
+ # Batch upsert — fewer Pinecone round-trips
365
+ db_tasks = []
366
+ if all_face_upserts:
367
+ db_tasks.append(asyncio.to_thread(idx_face.upsert, vectors=all_face_upserts))
368
+ if all_object_upserts:
369
+ db_tasks.append(asyncio.to_thread(idx_obj.upsert, vectors=all_object_upserts))
370
+ if db_tasks:
371
+ try:
372
+ await asyncio.gather(*db_tasks)
373
  except Exception as e:
374
+ raise HTTPException(500, f"Database insertion failed: {e}")
 
 
 
 
 
 
 
 
 
 
375
 
376
+ duration_ms = round((time.perf_counter() - start) * 1000)
377
  log("INFO", "upload.complete",
378
+ user_id=user_id or "anonymous", ip=ip,
379
+ files=len(files), folder=folder,
380
+ face_vectors=len(all_face_upserts),
381
+ object_vectors=len(all_object_upserts),
382
+ duration_ms=duration_ms)
383
+
384
+ return {
385
+ "message": "Done!",
386
+ "urls": uploaded_urls,
387
+ "summary": {
388
+ "files": len(files),
389
+ "face_vectors": len(all_face_upserts),
390
+ "object_vectors": len(all_object_upserts),
391
+ },
392
+ }
393
+
394
+
395
+ # ════════════════════════════════════════════════════════════════════
396
  # 3. SEARCH
397
+ # ════════════════════════════════════════════════════════════════════
398
+
399
  @app.post("/api/search")
400
  async def search_database(
401
  request: Request,
 
407
  ):
408
  ip = get_ip(request)
409
  start = time.perf_counter()
410
+ actual_pc_key = user_pinecone_key or DEFAULT_PINECONE_KEY
411
+ mode = "guest" if is_default_key(actual_pc_key, DEFAULT_PINECONE_KEY) else "personal"
412
 
413
  log("INFO", "search.start",
414
  user_id=user_id or "anonymous", ip=ip, mode=mode,
415
  filename=file.filename, detect_faces=detect_faces)
416
 
417
  if not actual_pc_key:
418
+ raise HTTPException(400, "Pinecone key is missing.")
 
419
 
420
  tmp_path = f"temp_uploads/query_{uuid.uuid4().hex}_{sanitize_filename(file.filename)}"
421
  try:
422
  with open(tmp_path, "wb") as buf:
423
  shutil.copyfileobj(file.file, buf)
424
 
425
+ async with _sem:
426
+ vectors = await _ai.process_image_async(tmp_path, detect_faces=detect_faces)
427
+
428
+ inference_ms = round((time.perf_counter() - start) * 1000)
429
+ face_vectors = [v for v in vectors if v["type"] == "face"]
430
+ object_vectors = [v for v in vectors if v["type"] == "object"]
431
+ lanes_used = list({v["type"] for v in vectors})
432
 
 
 
433
  log("INFO", "search.inference_done",
434
  user_id=user_id or "anonymous", ip=ip, mode=mode,
435
+ face_vecs=len(face_vectors), obj_vecs=len(object_vectors),
436
+ inference_ms=inference_ms)
437
 
438
+ pc = pinecone_pool.get(actual_pc_key)
439
  idx_obj = pc.Index(IDX_OBJECTS)
440
  idx_face = pc.Index(IDX_FACES)
441
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
  if detect_faces and face_vectors:
443
+ # ── FACE MODE ────────────────────────────────────────────
444
+ # Query faces index for each detected query face in parallel.
445
+ # Merge across faces so images matching multiple query faces rank higher.
446
+ # ALSO run object search on object vectors (not discarded anymore).
447
+
448
+ async def _query_face(fv: dict) -> dict:
449
+ vec = to_list(fv["vector"])
450
+ det_score = fv.get("det_score", 1.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  try:
452
+ image_map = await asyncio.to_thread(
453
+ search_faces, idx_face, vec, det_score)
 
 
 
 
454
  except Exception as e:
455
  if "404" in str(e):
456
+ raise HTTPException(404,
457
+ "Pinecone index not found. Go to Settings → Verify & Save.")
458
+ raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  return {
460
+ "query_face_idx": fv.get("face_idx", 0),
461
+ "query_face_crop": fv.get("face_crop", ""),
462
+ "query_bbox": fv.get("bbox", []),
463
  "det_score": det_score,
464
+ "face_width_px": fv.get("face_width_px", 0),
465
+ "_image_map": image_map,
466
+ # Per-face results (for UI face tabs)
467
+ "matches": sorted(
468
+ [
469
+ {
470
+ "url": url,
471
+ "score": face_ui_score(d["raw_score"]),
472
+ "raw_score": round(d["raw_score"], 4),
473
+ "face_crop": d["face_crop"],
474
+ "folder": d["folder"],
475
+ "caption": "👤 Verified Identity",
476
+ }
477
+ for url, d in image_map.items()
478
+ ],
479
+ key=lambda x: x["score"], reverse=True,
480
+ )[:50],
481
  }
482
 
483
+ async def _query_obj_single(ov: dict) -> list:
484
+ vec = to_list(ov["vector"])
485
+ try:
486
+ return await asyncio.to_thread(search_objects, idx_obj, vec)
487
+ except Exception as e:
488
+ if "404" in str(e):
489
+ raise HTTPException(404,
490
+ "Pinecone index not found. Go to Settings Verify & Save.")
491
+ raise
492
+
493
+ # Run all face queries and all object queries concurrently
494
+ face_tasks = [_query_face(fv) for fv in face_vectors]
495
+ obj_tasks = [_query_obj_single(ov) for ov in object_vectors]
496
+ all_results = await asyncio.gather(*face_tasks, *obj_tasks)
497
+
498
+ raw_groups = list(all_results[:len(face_tasks)])
499
+ obj_nested = list(all_results[len(face_tasks):])
500
+
501
+ merged_face = merge_face_results(raw_groups)
502
+ merged_objects = merge_object_results(obj_nested)
503
+
504
+ # Strip internal _image_map from per-face groups
505
+ face_groups = [
506
+ {k: v for k, v in g.items() if k != "_image_map"}
507
+ for g in raw_groups
508
+ if g.get("matches")
509
+ ]
510
+
511
+ duration_ms = round((time.perf_counter() - start) * 1000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  log("INFO", "search.complete",
513
  user_id=user_id or "anonymous", ip=ip, mode=mode,
514
+ lanes=["face", "object"], face_groups=len(face_groups),
515
+ face_results=len(merged_face), object_results=len(merged_objects),
516
+ top_face_score=merged_face[0]["score"] if merged_face else 0,
 
517
  duration_ms=duration_ms)
518
 
519
  return {
520
  "mode": "face",
521
+ "face_groups": face_groups, # per-face tabs for UI
522
+ "results": merged_face, # merged cross-face ranked list
523
+ "object_results": merged_objects, # semantic/visual matches (new)
524
  }
525
 
526
  else:
527
+ # ── OBJECT MODE ──────────────────────────────────────────
528
+ # Use object_vectors (not raw `vectors`) to avoid any
529
+ # potential 1024-D face vector being sent to the 1536-D index.
530
+ if not object_vectors:
531
+ return {"mode": "object", "results": [], "face_groups": []}
532
+
533
+ async def _query_obj(ov: dict) -> list:
534
+ vec = to_list(ov["vector"])
535
+ try:
536
+ return await asyncio.to_thread(search_objects, idx_obj, vec)
537
+ except Exception as e:
538
+ if "404" in str(e):
539
+ raise HTTPException(404,
540
+ "Pinecone index not found. Go to Settings → Verify & Save.")
541
+ raise
542
+
543
+ nested = await asyncio.gather(*[_query_obj(ov) for ov in object_vectors])
544
+ final = merge_object_results(nested)
545
 
546
  duration_ms = round((time.perf_counter() - start) * 1000)
547
  log("INFO", "search.complete",
548
  user_id=user_id or "anonymous", ip=ip, mode=mode,
549
+ lanes=lanes_used, results=len(final),
 
550
  top_score=final[0]["score"] if final else 0,
551
  duration_ms=duration_ms)
552
 
553
  return {"mode": "object", "results": final, "face_groups": []}
554
 
555
+ except HTTPException:
556
+ raise
557
  except Exception as e:
558
  log("ERROR", "search.error",
559
  user_id=user_id or "anonymous", ip=ip, mode=mode,
560
  error=str(e), traceback=traceback.format_exc()[-800:],
561
+ duration_ms=round((time.perf_counter() - start) * 1000))
562
  raise HTTPException(500, str(e))
563
  finally:
564
+ if os.path.exists(tmp_path):
565
+ os.remove(tmp_path)
566
 
567
 
568
+ # ════════════════════════════════════════════════════════════════════
569
  # 4. CATEGORIES
570
+ # ════════════════════════════════════════════════════════════════════
571
+
572
  @app.post("/api/categories")
573
  async def get_categories(
574
  request: Request,
 
576
  user_id: str = Form(""),
577
  ):
578
  ip = get_ip(request)
579
+ actual_url = user_cloudinary_url or DEFAULT_CLOUDINARY_URL
580
+ if not actual_url:
581
+ return {"categories": []}
582
  try:
583
  creds = get_cloudinary_creds(actual_url)
584
+ if not creds.get("cloud_name"):
585
+ return {"categories": []}
586
+ result = await asyncio.to_thread(cld_root_folders, creds)
587
  categories = [f["name"] for f in result.get("folders", [])]
588
  log("INFO", "categories.fetched",
589
+ user_id=user_id or "anonymous", ip=ip, count=len(categories))
590
  return {"categories": categories}
591
  except Exception as e:
592
+ log("ERROR", "categories.error",
593
+ user_id=user_id or "anonymous", ip=ip, error=str(e))
594
  return {"categories": []}
595
 
596
 
597
+ # ════════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
598
  # 5. LIST FOLDER IMAGES
599
+ # ════════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
 
601
  @app.post("/api/cloudinary/folder-images")
602
  async def list_folder_images(
 
608
  page_size: int = Form(100),
609
  ):
610
  ip = get_ip(request)
611
+ actual_url = user_cloudinary_url or DEFAULT_CLOUDINARY_URL
612
  creds = get_cloudinary_creds(actual_url)
613
+ if not creds.get("cloud_name"):
614
+ raise HTTPException(400, "Invalid Cloudinary URL.")
615
 
616
+ result = await asyncio.to_thread(
617
+ cld_list_folder_images,
618
+ folder_name, creds, next_cursor or None, page_size,
 
619
  )
620
+
621
+ images = [
622
+ {
623
+ "url": r["secure_url"],
624
+ "thumb_url": cld_thumb_url(r["secure_url"]),
 
 
625
  "public_id": r["public_id"],
626
+ }
627
+ for r in result.get("resources", [])
628
+ ]
629
+ next_cur = result.get("next_cursor") or ""
630
 
 
631
  log("INFO", "explorer.folder_opened",
632
  user_id=user_id or "anonymous", ip=ip,
633
+ folder=folder_name, count=len(images), has_more=bool(next_cur))
634
+ return {"images": images, "count": len(images), "next_cursor": next_cur}
 
635
 
636
 
637
+ # ════════════════════════════════════════════════════════════════════
638
  # 6. DELETE SINGLE IMAGE
639
+ # ════════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
640
 
641
  @app.post("/api/delete-image")
642
  async def delete_image(
 
648
  user_id: str = Form(""),
649
  ):
650
  ip = get_ip(request)
651
+ actual_pc_key = user_pinecone_key or DEFAULT_PINECONE_KEY
652
+ actual_cld_url = user_cloudinary_url or DEFAULT_CLOUDINARY_URL
653
  creds = get_cloudinary_creds(actual_cld_url)
654
+ if not creds.get("cloud_name"):
655
+ raise HTTPException(400, "Invalid Cloudinary URL.")
656
+
657
+ pid = public_id or url_to_public_id(image_url)
658
+ if not pid:
659
+ raise HTTPException(400, "Could not determine public_id.")
660
+
661
+ await asyncio.to_thread(cld_delete_resource, pid, creds)
662
+
663
  if actual_pc_key and image_url:
664
  try:
665
+ pc = pinecone_pool.get(actual_pc_key)
666
  for idx_name in [IDX_OBJECTS, IDX_FACES]:
667
+ await asyncio.to_thread(
668
+ pc.Index(idx_name).delete,
669
+ filter={"url": {"$eq": image_url}},
670
+ )
671
  except Exception as e:
672
  _log_fn("WARNING", f"Pinecone delete warning: {e}")
673
+
674
  log("INFO", "explorer.image_deleted",
675
  user_id=user_id or "anonymous", ip=ip,
676
  image_url=image_url, public_id=pid)
677
  return {"message": "Image deleted successfully."}
678
 
679
 
680
+ # ════════════════════════════════════════════════════════════════════
681
  # 7. DELETE ENTIRE FOLDER
682
+ # ════════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
683
 
684
  @app.post("/api/delete-folder")
685
  async def delete_folder(
 
690
  user_id: str = Form(""),
691
  ):
692
  ip = get_ip(request)
693
+ actual_pc_key = user_pinecone_key or DEFAULT_PINECONE_KEY
694
+ actual_cld_url = user_cloudinary_url or DEFAULT_CLOUDINARY_URL
695
  creds = get_cloudinary_creds(actual_cld_url)
696
+ if not creds.get("cloud_name"):
697
+ raise HTTPException(400, "Invalid Cloudinary URL.")
698
+
699
+ # Collect all images first (needed for Pinecone URL-filter fallback)
700
+ all_images, cursor = [], None
701
  while True:
702
+ result = await asyncio.to_thread(
703
+ cld_list_folder_images, folder_name, creds, cursor)
704
  all_images.extend(result.get("resources", []))
705
+ cursor = result.get("next_cursor")
706
+ if not cursor:
707
+ break
708
+
709
+ # Delete resources then remove folder record
710
+ await asyncio.to_thread(cld_delete_folder_resources, folder_name, creds)
711
+ await asyncio.to_thread(cld_remove_folder, folder_name, creds)
712
+
713
  if actual_pc_key:
714
  try:
715
+ pc = pinecone_pool.get(actual_pc_key)
716
  for idx_name in [IDX_OBJECTS, IDX_FACES]:
717
  idx = pc.Index(idx_name)
718
  try:
719
+ await asyncio.to_thread(
720
+ idx.delete, filter={"folder": {"$eq": folder_name}})
721
  except Exception:
722
+ # Fallback: delete by individual URL if filter-delete unsupported
723
  for img in all_images:
724
+ url = img.get("secure_url", "")
725
+ if url:
726
+ try:
727
+ await asyncio.to_thread(
728
+ idx.delete, filter={"url": {"$eq": url}})
729
+ except Exception:
730
+ pass
731
  except Exception as e:
732
  _log_fn("WARNING", f"Pinecone folder delete warning: {e}")
733
+
734
  log("INFO", "explorer.folder_deleted",
735
  user_id=user_id or "anonymous", ip=ip,
736
+ folder=folder_name, deleted_count=len(all_images))
737
+ return {
738
+ "message": f"Folder '{folder_name}' and all its contents deleted.",
739
+ "deleted_count": len(all_images),
740
+ }
741
+
742
 
743
+ # ════════════════════════════════════════════════════════════════════
744
+ # 8. RESET DATABASE ⚠️ DESTRUCTIVE
745
+ # ════════════════════════════════════════════════════════════════════
746
 
 
 
 
747
  @app.post("/api/reset-database")
748
  async def reset_database(
749
  request: Request,
 
753
  ):
754
  ip = get_ip(request)
755
  start = time.perf_counter()
 
756
  log("WARNING", "danger.reset_database.attempt",
757
  user_id=user_id or "anonymous", ip=ip)
758
 
759
+ if (is_default_key(user_pinecone_key, DEFAULT_PINECONE_KEY) or
760
+ is_default_key(user_cloudinary_url, DEFAULT_CLOUDINARY_URL)):
761
+ log("WARNING", "danger.reset_database.blocked",
762
  user_id=user_id or "anonymous", ip=ip)
763
  raise HTTPException(403, "Reset is not allowed on the shared demo database.")
764
 
765
  creds = get_cloudinary_creds(user_cloudinary_url)
766
+ if not creds.get("cloud_name"):
767
+ raise HTTPException(400, "Invalid Cloudinary URL.")
768
 
769
+ # Wipe Cloudinary
770
  try:
771
+ deleted = await asyncio.to_thread(cld_delete_all_paginated, creds)
772
  _log_fn("INFO", f"Cloudinary: deleted {deleted} resources")
773
  except Exception as e:
774
  _log_fn("WARNING", f"Cloudinary wipe: {e}")
775
 
776
  try:
777
+ folders_res = await asyncio.to_thread(cld_root_folders, creds)
 
778
  folder_tasks = [
779
+ asyncio.to_thread(cld_remove_folder, f["name"], creds)
780
  for f in folders_res.get("folders", [])
781
  ]
782
  if folder_tasks:
 
784
  except Exception as e:
785
  _log_fn("WARNING", f"Cloudinary folder cleanup: {e}")
786
 
787
+ # Wipe and recreate Pinecone indexes
788
  try:
789
+ pc = pinecone_pool.get(user_pinecone_key)
790
+ await asyncio.to_thread(delete_and_recreate_indexes, pc)
 
 
 
 
 
 
 
 
 
 
 
 
 
791
  except Exception as e:
792
  log("ERROR", "danger.reset_database.pinecone_error",
793
  user_id=user_id or "anonymous", ip=ip, error=str(e))
 
795
 
796
  log("WARNING", "danger.reset_database.complete",
797
  user_id=user_id or "anonymous", ip=ip,
798
+ duration_ms=round((time.perf_counter() - start) * 1000))
799
  return {"message": "Database reset complete. All data wiped and indexes recreated."}
800
 
801
 
802
+ # ════════════════════════════════════════════════════════════════════
803
+ # 9. DELETE ACCOUNT ⚠️ DESTRUCTIVE
804
+ # ════════════════════════════════════════════════════════════════════
805
+
806
  @app.post("/api/delete-account")
807
  async def delete_account(
808
  request: Request,
 
812
  ):
813
  ip = get_ip(request)
814
  start = time.perf_counter()
 
815
  log("WARNING", "danger.delete_account.attempt",
816
  user_id=user_id or "anonymous", ip=ip)
817
 
818
+ if (is_default_key(user_pinecone_key, DEFAULT_PINECONE_KEY) or
819
+ is_default_key(user_cloudinary_url, DEFAULT_CLOUDINARY_URL)):
820
+ log("WARNING", "danger.delete_account.blocked",
821
  user_id=user_id or "anonymous", ip=ip)
822
  raise HTTPException(403, "Account deletion is not allowed on the shared demo database.")
823
 
824
  creds = get_cloudinary_creds(user_cloudinary_url)
825
 
 
826
  try:
827
+ deleted = await asyncio.to_thread(cld_delete_all_paginated, creds)
828
+ _log_fn("INFO", f"Account delete: {deleted} Cloudinary resources removed")
829
  except Exception as e:
830
  _log_fn("WARNING", f"Account delete Cloudinary: {e}")
831
 
832
  try:
833
+ folders_res = await asyncio.to_thread(cld_root_folders, creds)
834
  folder_tasks = [
835
+ asyncio.to_thread(cld_remove_folder, f["name"], creds)
836
  for f in folders_res.get("folders", [])
837
  ]
838
  if folder_tasks:
839
  await asyncio.gather(*folder_tasks, return_exceptions=True)
840
  except Exception as e:
841
+ _log_fn("WARNING", f"Account delete folders: {e}")
842
 
 
843
  try:
844
+ pc = pinecone_pool.get(user_pinecone_key)
845
  existing = {idx.name for idx in await asyncio.to_thread(pc.list_indexes)}
846
+ tasks = [
847
+ asyncio.to_thread(pc.delete_index, name)
848
+ for name in [IDX_OBJECTS, IDX_FACES]
849
+ if name in existing
850
+ ]
851
+ if tasks:
852
+ await asyncio.gather(*tasks)
853
  except Exception as e:
854
  _log_fn("WARNING", f"Account delete Pinecone: {e}")
855
 
856
  log("WARNING", "danger.delete_account.complete",
857
  user_id=user_id or "anonymous", ip=ip,
858
+ duration_ms=round((time.perf_counter() - start) * 1000))
859
  return {"message": "Account data deleted. Sign out initiated."}