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