AdarshDRC commited on
Commit
047ce55
·
verified ·
1 Parent(s): ea3e671

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +24 -838
main.py CHANGED
@@ -1,859 +1,45 @@
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()
68
- _loguru.add(
69
- lambda msg: print(msg, end=""),
70
- format="<green>{time:HH:mm:ss}</green> | <level>{level:<8}</level> | {message}",
71
- level="DEBUG", colorize=True,
72
- )
73
- _log_fn = _loguru.log
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:
87
- import aiohttp
88
- row = {
89
- "level": level.upper(),
90
- "event": event,
91
- "user_id": str(data.get("user_id", "anonymous")),
92
- "ip": str(data.get("ip", "")),
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 = {
100
- "Content-Type": "application/json",
101
- "apikey": SUPABASE_SERVICE_KEY,
102
- "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}",
103
- "Prefer": "return=minimal",
104
- }
105
- async with aiohttp.ClientSession() as s:
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,
207
- pinecone_key: str = Form(""),
208
- cloudinary_url: str = Form(""),
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,
402
- file: UploadFile = File(...),
403
- detect_faces: bool = Form(True),
404
- user_pinecone_key: str = Form(""),
405
- user_cloudinary_url: str = Form(""),
406
- user_id: str = Form(""),
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,
575
- user_cloudinary_url: str = Form(""),
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(
603
- request: Request,
604
- user_cloudinary_url: str = Form(""),
605
- folder_name: str = Form(...),
606
- user_id: str = Form(""),
607
- next_cursor: str = Form(""),
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(
643
- request: Request,
644
- user_pinecone_key: str = Form(""),
645
- user_cloudinary_url: str = Form(""),
646
- image_url: str = Form(""),
647
- public_id: str = Form(""),
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(
686
- request: Request,
687
- user_pinecone_key: str = Form(""),
688
- user_cloudinary_url: str = Form(""),
689
- folder_name: str = Form(...),
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,
750
- user_pinecone_key: str = Form(""),
751
- user_cloudinary_url: str = Form(""),
752
- user_id: str = Form(""),
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:
783
- await asyncio.gather(*folder_tasks, return_exceptions=True)
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))
794
- raise HTTPException(500, f"Pinecone reset error: {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,
809
- user_pinecone_key: str = Form(""),
810
- user_cloudinary_url: str = Form(""),
811
- user_id: str = Form(""),
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."}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import asyncio
 
 
2
  import os
 
 
 
 
 
3
  from contextlib import asynccontextmanager
 
 
4
 
5
+ from fastapi import FastAPI
 
6
  from fastapi.middleware.cors import CORSMiddleware
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
+ from src.core.config import MAX_CONCURRENT_INFERENCES
9
+ from src.core.logging import log, init_logging_session, close_logging_session
10
+ from src.api import danger, explorer, search, system, upload
 
11
 
12
  @asynccontextmanager
13
  async def lifespan(app: FastAPI):
14
+ await init_logging_session()
 
 
 
 
 
 
15
  log("INFO", "server.startup", message="Loading AI models...")
16
+
17
+ from src.services.ai_manager import AIModelManager
18
+
19
  loop = asyncio.get_event_loop()
20
+ app.state.ai = await loop.run_in_executor(None, AIModelManager)
21
+ app.state.ai_semaphore = asyncio.Semaphore(MAX_CONCURRENT_INFERENCES)
22
+
23
  log("INFO", "server.ready", message="All models loaded. API ready.")
24
  yield
25
+
26
  log("INFO", "server.shutdown", message="API shutting down.")
27
+ await close_logging_session()
28
 
29
  app = FastAPI(lifespan=lifespan)
30
+
31
  app.add_middleware(
32
  CORSMiddleware,
33
+ allow_origins=["*"], # Update this to your frontend URL in production
34
+ allow_credentials=True,
35
+ allow_methods=["*"],
36
+ allow_headers=["*"],
37
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
+ os.makedirs("temp_uploads", exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
+ app.include_router(system.router)
42
+ app.include_router(upload.router)
43
+ app.include_router(search.router)
44
+ app.include_router(explorer.router)
45
+ app.include_router(danger.router)