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

Update main.py

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