USAMA BHATTI commited on
Commit
ba2fc46
·
1 Parent(s): a833774

Feat: Added Visual Search, API Key Auth, and Docker Optimization

Browse files
Files changed (49) hide show
  1. .dockerignore +1 -0
  2. .gitignore +2 -0
  3. Dockerfile +1 -0
  4. Procfile +1 -0
  5. backend/src/api/routes/auth.py +1 -0
  6. backend/src/api/routes/chat.py +102 -16
  7. backend/src/api/routes/deps.py +91 -3
  8. backend/src/api/routes/ingestion.py +1 -0
  9. backend/src/api/routes/settings.py +1 -1
  10. backend/src/api/routes/visual.py +456 -0
  11. backend/src/core/config.py +1 -0
  12. backend/src/db/session.py +27 -9
  13. backend/src/init_db.py +1 -0
  14. backend/src/main.py +11 -1
  15. backend/src/models/ingestion.py +1 -0
  16. backend/src/models/integration.py +1 -1
  17. backend/src/models/user.py +1 -0
  18. backend/src/schemas/chat.py +1 -0
  19. backend/src/services/chat_service.py +52 -23
  20. backend/src/services/connectors/base.py +1 -0
  21. backend/src/services/connectors/cms_base.py +1 -0
  22. backend/src/services/connectors/mongo_connector.py +1 -1
  23. backend/src/services/connectors/sanity_connector.py +1 -1
  24. backend/src/services/connectors/shopify_connector.py +70 -0
  25. backend/src/services/connectors/woocommerce_connector.py +76 -0
  26. backend/src/services/ingestion/crawler.py +1 -0
  27. backend/src/services/ingestion/file_processor.py +1 -0
  28. backend/src/services/ingestion/guardrail_factory.py +1 -0
  29. backend/src/services/ingestion/web_processor.py +1 -0
  30. backend/src/services/ingestion/zip_processor.py +1 -0
  31. backend/src/services/llm/factory.py +1 -1
  32. backend/src/services/routing/semantic_router.py +1 -0
  33. backend/src/services/security/pii_scrubber.py +1 -0
  34. backend/src/services/tools/cms_agent.py +1 -1
  35. backend/src/services/tools/cms_tool.py +1 -1
  36. backend/src/services/tools/nosql_agent.py +1 -1
  37. backend/src/services/tools/nosql_tool.py +1 -1
  38. backend/src/services/tools/secure_agent.py +1 -1
  39. backend/src/services/tools/sql_tool.py +1 -1
  40. backend/src/services/visual/agent.py +524 -0
  41. backend/src/services/visual/engine.py +74 -0
  42. backend/src/update_db.py +57 -0
  43. backend/src/utils/auth.py +1 -0
  44. backend/src/utils/security.py +42 -11
  45. docker-compose.yml +1 -0
  46. frontend/visual_search_test.html +68 -0
  47. frontend/visual_search_test.js +132 -0
  48. requirements.txt +8 -3
  49. static/widget.js +198 -92
.dockerignore CHANGED
@@ -1,3 +1,4 @@
 
1
  # Python
2
  __pycache__
3
  *.pyc
 
1
+ # Docker Ignore File
2
  # Python
3
  __pycache__
4
  *.pyc
.gitignore CHANGED
@@ -1,3 +1,5 @@
 
 
1
  # --- Security (Inhein kabhi upload mat karna) ---
2
  .env
3
  .env.local
 
1
+ # --- Git Ignore File---
2
+ # --- Byte-compiled / optimized / DLL files ---
3
  # --- Security (Inhein kabhi upload mat karna) ---
4
  .env
5
  .env.local
Dockerfile CHANGED
@@ -1,3 +1,4 @@
 
1
  # 1. Base Image
2
  FROM python:3.11-slim
3
 
 
1
+ # Dockerfile for Python FastAPI Application
2
  # 1. Base Image
3
  FROM python:3.11-slim
4
 
Procfile CHANGED
@@ -1 +1,2 @@
 
1
  web: uvicorn backend.src.main:app --host 0.0.0.0 --port $PORT
 
1
+
2
  web: uvicorn backend.src.main:app --host 0.0.0.0 --port $PORT
backend/src/api/routes/auth.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from fastapi import APIRouter, Depends, HTTPException, status
2
  from fastapi.security import OAuth2PasswordRequestForm
3
  from sqlalchemy.ext.asyncio import AsyncSession
 
1
+ # backend/src/api/routes/auth.py
2
  from fastapi import APIRouter, Depends, HTTPException, status
3
  from fastapi.security import OAuth2PasswordRequestForm
4
  from sqlalchemy.ext.asyncio import AsyncSession
backend/src/api/routes/chat.py CHANGED
@@ -1,6 +1,66 @@
1
- from fastapi import APIRouter, Depends, HTTPException, Request
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from sqlalchemy.ext.asyncio import AsyncSession
3
  from sqlalchemy.future import select
 
4
  from backend.src.db.session import get_db
5
  from backend.src.schemas.chat import ChatRequest, ChatResponse
6
  from backend.src.services.chat_service import process_chat
@@ -8,6 +68,31 @@ from backend.src.models.user import User
8
 
9
  router = APIRouter()
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  @router.post("/chat", response_model=ChatResponse)
12
  async def chat_endpoint(
13
  request_body: ChatRequest,
@@ -15,28 +100,29 @@ async def chat_endpoint(
15
  db: AsyncSession = Depends(get_db)
16
  ):
17
  try:
18
- # 1. API Key se Bot Owner (User) ko dhoondo
 
19
  stmt = select(User).where(User.api_key == request_body.api_key)
20
  result = await db.execute(stmt)
21
  bot_owner = result.scalars().first()
22
 
23
  if not bot_owner:
24
- raise HTTPException(status_code=401, detail="Invalid API Key. Unauthorized access.")
25
-
26
- # 2. DOMAIN LOCK LOGIC (Whitelisting)
27
- # Browser automatically 'origin' ya 'referer' header bhejta hai
28
- client_origin = request.headers.get("origin") or request.headers.get("referer") or ""
29
 
30
- if bot_owner.allowed_domains != "*":
31
- allowed = [d.strip() for d in bot_owner.allowed_domains.split(",")]
32
- # Check if client_origin contains any of the allowed domains
33
- is_authorized = any(domain in client_origin for domain in allowed)
34
-
35
- if not is_authorized:
36
- print(f"🚫 Blocked unauthorized domain: {client_origin}")
37
- raise HTTPException(status_code=403, detail="Domain not authorized to use this bot.")
 
38
 
39
- # 3. Process Chat (Using the bot_owner's credentials)
40
  session_id = request_body.session_id or f"guest_{bot_owner.id}"
41
 
42
  response_text = await process_chat(
 
1
+ # # backend/src/api/routes/chat.py
2
+ # from fastapi import APIRouter, Depends, HTTPException, Request
3
+ # from sqlalchemy.ext.asyncio import AsyncSession
4
+ # from sqlalchemy.future import select
5
+ # from backend.src.db.session import get_db
6
+ # from backend.src.schemas.chat import ChatRequest, ChatResponse
7
+ # from backend.src.services.chat_service import process_chat
8
+ # from backend.src.models.user import User
9
+
10
+ # router = APIRouter()
11
+
12
+ # @router.post("/chat", response_model=ChatResponse)
13
+ # async def chat_endpoint(
14
+ # request_body: ChatRequest,
15
+ # request: Request, # Browser headers read karne ke liye
16
+ # db: AsyncSession = Depends(get_db)
17
+ # ):
18
+ # try:
19
+ # # 1. API Key se Bot Owner (User) ko dhoondo
20
+ # stmt = select(User).where(User.api_key == request_body.api_key)
21
+ # result = await db.execute(stmt)
22
+ # bot_owner = result.scalars().first()
23
+
24
+ # if not bot_owner:
25
+ # raise HTTPException(status_code=401, detail="Invalid API Key. Unauthorized access.")
26
+
27
+ # # 2. DOMAIN LOCK LOGIC (Whitelisting)
28
+ # # Browser automatically 'origin' ya 'referer' header bhejta hai
29
+ # client_origin = request.headers.get("origin") or request.headers.get("referer") or ""
30
+
31
+ # if bot_owner.allowed_domains != "*":
32
+ # allowed = [d.strip() for d in bot_owner.allowed_domains.split(",")]
33
+ # # Check if client_origin contains any of the allowed domains
34
+ # is_authorized = any(domain in client_origin for domain in allowed)
35
+
36
+ # if not is_authorized:
37
+ # print(f"🚫 Blocked unauthorized domain: {client_origin}")
38
+ # raise HTTPException(status_code=403, detail="Domain not authorized to use this bot.")
39
+
40
+ # # 3. Process Chat (Using the bot_owner's credentials)
41
+ # session_id = request_body.session_id or f"guest_{bot_owner.id}"
42
+
43
+ # response_text = await process_chat(
44
+ # message=request_body.message,
45
+ # session_id=session_id,
46
+ # user_id=str(bot_owner.id), # Owner ki ID use hogi DB lookup ke liye
47
+ # db=db
48
+ # )
49
+
50
+ # return ChatResponse(
51
+ # response=response_text,
52
+ # session_id=session_id,
53
+ # provider="omni_agent"
54
+ # )
55
+
56
+ # except HTTPException as he: raise he
57
+ # except Exception as e:
58
+ # print(f"❌ Chat Error: {e}")
59
+ # raise HTTPException(status_code=500, detail="AI Service Interrupted.")
60
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
61
  from sqlalchemy.ext.asyncio import AsyncSession
62
  from sqlalchemy.future import select
63
+
64
  from backend.src.db.session import get_db
65
  from backend.src.schemas.chat import ChatRequest, ChatResponse
66
  from backend.src.services.chat_service import process_chat
 
68
 
69
  router = APIRouter()
70
 
71
+ # --- HELPER: DOMAIN SECURITY (Standardized) ---
72
+ def verify_domain_access(user: User, request: Request):
73
+ """
74
+ Checks if the incoming request is from an allowed domain.
75
+ """
76
+ # 1. Browser headers check karein
77
+ client_origin = request.headers.get("origin") or request.headers.get("referer") or ""
78
+
79
+ # 2. Agar user ne "*" set kiya hai, to sab allow hai
80
+ if user.allowed_domains == "*":
81
+ return True
82
+
83
+ # 3. Allowed domains ki list banao
84
+ allowed = [d.strip() for d in user.allowed_domains.split(",")]
85
+
86
+ # 4. Check karo ke origin match karta hai ya nahi
87
+ is_authorized = any(domain in client_origin for domain in allowed)
88
+
89
+ if not is_authorized:
90
+ print(f"🚫 [Chat Security] Blocked unauthorized domain: {client_origin}")
91
+ raise HTTPException(
92
+ status_code=status.HTTP_403_FORBIDDEN,
93
+ detail="Domain not authorized to use this bot."
94
+ )
95
+
96
  @router.post("/chat", response_model=ChatResponse)
97
  async def chat_endpoint(
98
  request_body: ChatRequest,
 
100
  db: AsyncSession = Depends(get_db)
101
  ):
102
  try:
103
+ # 1. AUTH: API Key se Bot Owner (User) ko dhoondo
104
+ # (Note: Chat Widget Body mein key bhejta hai, isliye hum Header wala dependency use nahi kar rahe yahan)
105
  stmt = select(User).where(User.api_key == request_body.api_key)
106
  result = await db.execute(stmt)
107
  bot_owner = result.scalars().first()
108
 
109
  if not bot_owner:
110
+ raise HTTPException(
111
+ status_code=status.HTTP_401_UNAUTHORIZED,
112
+ detail="Invalid API Key. Unauthorized access."
113
+ )
 
114
 
115
+ # Check if user is active
116
+ if not bot_owner.is_active:
117
+ raise HTTPException(
118
+ status_code=status.HTTP_401_UNAUTHORIZED,
119
+ detail="Bot owner account is inactive."
120
+ )
121
+
122
+ # 2. SECURITY: Domain Lock Check 🔐
123
+ verify_domain_access(bot_owner, request)
124
 
125
+ # 3. PROCESS: Chat Logic (Using the bot_owner's credentials)
126
  session_id = request_body.session_id or f"guest_{bot_owner.id}"
127
 
128
  response_text = await process_chat(
backend/src/api/routes/deps.py CHANGED
@@ -1,4 +1,50 @@
1
- from fastapi import Depends, HTTPException, status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from fastapi.security import OAuth2PasswordBearer
3
  from jose import jwt, JWTError
4
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,15 +56,18 @@ from backend.src.models.user import User
10
  from backend.src.utils.auth import ALGORITHM
11
 
12
  # Ye Swagger UI ko batata hai ke Token kahan se lena hai (/auth/login se)
 
13
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
14
 
 
 
 
15
  async def get_current_user(
16
  token: str = Depends(oauth2_scheme),
17
  db: AsyncSession = Depends(get_db)
18
  ) -> User:
19
  """
20
- Ye function har protected route se pehle chalega.
21
- Ye Token ko verify karega aur Database se User nikal kar dega.
22
  """
23
  credentials_exception = HTTPException(
24
  status_code=status.HTTP_401_UNAUTHORIZED,
@@ -42,4 +91,43 @@ async def get_current_user(
42
  if user is None:
43
  raise credentials_exception
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  return user
 
1
+ # # backend/src/api/routes/deps.py
2
+ # from fastapi import Depends, HTTPException, status
3
+ # from fastapi.security import OAuth2PasswordBearer
4
+ # from jose import jwt, JWTError
5
+ # from sqlalchemy.ext.asyncio import AsyncSession
6
+ # from sqlalchemy.future import select
7
+
8
+ # from backend.src.core.config import settings
9
+ # from backend.src.db.session import get_db
10
+ # from backend.src.models.user import User
11
+ # from backend.src.utils.auth import ALGORITHM
12
+
13
+ # # Ye Swagger UI ko batata hai ke Token kahan se lena hai (/auth/login se)
14
+ # oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
15
+
16
+ # async def get_current_user(
17
+ # token: str = Depends(oauth2_scheme),
18
+ # db: AsyncSession = Depends(get_db)
19
+ # ) -> User:
20
+ # """
21
+ # Ye function har protected route se pehle chalega.
22
+ # Ye Token ko verify karega aur Database se User nikal kar dega.
23
+ # """
24
+ # credentials_exception = HTTPException(
25
+ # status_code=status.HTTP_401_UNAUTHORIZED,
26
+ # detail="Could not validate credentials",
27
+ # headers={"WWW-Authenticate": "Bearer"},
28
+ # )
29
+
30
+ # try:
31
+ # # Token Decode karo
32
+ # payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
33
+ # user_id: str = payload.get("sub")
34
+ # if user_id is None:
35
+ # raise credentials_exception
36
+ # except JWTError:
37
+ # raise credentials_exception
38
+
39
+ # # Database mein User check karo
40
+ # result = await db.execute(select(User).where(User.id == int(user_id)))
41
+ # user = result.scalars().first()
42
+
43
+ # if user is None:
44
+ # raise credentials_exception
45
+
46
+ # return user
47
+ from fastapi import Depends, HTTPException, status, Header
48
  from fastapi.security import OAuth2PasswordBearer
49
  from jose import jwt, JWTError
50
  from sqlalchemy.ext.asyncio import AsyncSession
 
56
  from backend.src.utils.auth import ALGORITHM
57
 
58
  # Ye Swagger UI ko batata hai ke Token kahan se lena hai (/auth/login se)
59
+ # Ye Dashboard access ke liye zaroori hai
60
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
61
 
62
+ # ============================================================
63
+ # 1. JWT AUTHENTICATION (For Dashboard / Settings Access)
64
+ # ============================================================
65
  async def get_current_user(
66
  token: str = Depends(oauth2_scheme),
67
  db: AsyncSession = Depends(get_db)
68
  ) -> User:
69
  """
70
+ Ye function Internal Dashboard ke liye hai (Login required).
 
71
  """
72
  credentials_exception = HTTPException(
73
  status_code=status.HTTP_401_UNAUTHORIZED,
 
91
  if user is None:
92
  raise credentials_exception
93
 
94
+ return user
95
+
96
+ # ============================================================
97
+ # 2. API KEY AUTHENTICATION (For Public Widgets: Chat/Visual) 🔐
98
+ # ============================================================
99
+ async def get_current_user_by_api_key(
100
+ # Frontend se header aayega: 'x-api-key: omni_abcdef...'
101
+ api_key_header: str = Header(..., alias="x-api-key"),
102
+ db: AsyncSession = Depends(get_db)
103
+ ) -> User:
104
+ """
105
+ Ye function External Widgets (Chatbot, Visual Search) ke liye hai.
106
+ Ye JWT nahi maangta, sirf API Key maangta hai.
107
+ """
108
+ if not api_key_header:
109
+ raise HTTPException(
110
+ status_code=status.HTTP_401_UNAUTHORIZED,
111
+ detail="API Key missing in header"
112
+ )
113
+
114
+ # 1. Database mein API Key check karo
115
+ # Hum 'User' table mein dhoond rahe hain jiske paas ye key ho
116
+ stmt = select(User).where(User.api_key == api_key_header)
117
+ result = await db.execute(stmt)
118
+ user = result.scalars().first()
119
+
120
+ # 2. Validation
121
+ if user is None:
122
+ raise HTTPException(
123
+ status_code=status.HTTP_401_UNAUTHORIZED,
124
+ detail="Invalid API Key provided."
125
+ )
126
+
127
+ if not user.is_active:
128
+ raise HTTPException(
129
+ status_code=status.HTTP_401_UNAUTHORIZED,
130
+ detail="User account is inactive."
131
+ )
132
+
133
  return user
backend/src/api/routes/ingestion.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import os
2
  import shutil
3
  from fastapi import APIRouter, UploadFile, File, HTTPException, Form, BackgroundTasks, Depends
 
1
+ # backend/src/api/routes/ingestion.py
2
  import os
3
  import shutil
4
  from fastapi import APIRouter, UploadFile, File, HTTPException, Form, BackgroundTasks, Depends
backend/src/api/routes/settings.py CHANGED
@@ -1,4 +1,4 @@
1
-
2
  # --- ---
3
  import json
4
  from fastapi import APIRouter, Depends, HTTPException, status
 
1
+ # backend/src/api/routes/settings.py
2
  # --- ---
3
  import json
4
  from fastapi import APIRouter, Depends, HTTPException, status
backend/src/api/routes/visual.py ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # # backend/src/api/routes/visual.py
2
+ # import json
3
+ # import asyncio
4
+ # from fastapi import (
5
+ # APIRouter,
6
+ # Depends,
7
+ # UploadFile,
8
+ # File,
9
+ # HTTPException,
10
+ # BackgroundTasks
11
+ # )
12
+ # from sqlalchemy.ext.asyncio import AsyncSession
13
+ # from sqlalchemy.future import select
14
+ # from qdrant_client import QdrantClient
15
+ # from qdrant_client.http import models
16
+
17
+ # # =========================
18
+ # # Auth & DB Imports
19
+ # # =========================
20
+ # from backend.src.api.routes.deps import get_current_user
21
+ # from backend.src.db.session import get_db, AsyncSessionLocal
22
+ # from backend.src.models.user import User
23
+ # from backend.src.models.integration import UserIntegration
24
+ # from backend.src.models.ingestion import IngestionJob, JobStatus
25
+
26
+ # # =========================
27
+ # # Visual Services
28
+ # # =========================
29
+ # from backend.src.services.visual.engine import get_image_embedding
30
+ # from backend.src.services.visual.agent import run_visual_sync
31
+
32
+ # router = APIRouter()
33
+
34
+ # # ======================================================
35
+ # # 1. VISUAL SYNC (BACKGROUND)
36
+ # # ======================================================
37
+ # @router.post("/visual/sync")
38
+ # async def trigger_visual_sync(
39
+ # background_tasks: BackgroundTasks,
40
+ # db: AsyncSession = Depends(get_db),
41
+ # current_user: User = Depends(get_current_user)
42
+ # ):
43
+ # try:
44
+ # job = IngestionJob(
45
+ # session_id=f"visual_sync_{current_user.id}",
46
+ # ingestion_type="visual_sync",
47
+ # source_name="Store Integration (Visual)",
48
+ # status=JobStatus.PENDING,
49
+ # total_items=0,
50
+ # items_processed=0
51
+ # )
52
+
53
+ # db.add(job)
54
+ # await db.commit()
55
+ # await db.refresh(job)
56
+
57
+ # background_tasks.add_task(
58
+ # run_visual_sync,
59
+ # str(current_user.id),
60
+ # job.id,
61
+ # AsyncSessionLocal
62
+ # )
63
+
64
+ # return {
65
+ # "status": "processing",
66
+ # "message": "Visual Sync started successfully.",
67
+ # "job_id": job.id
68
+ # }
69
+
70
+ # except Exception as e:
71
+ # print(f"❌ Visual Sync Failed: {e}")
72
+ # raise HTTPException(status_code=500, detail=str(e))
73
+
74
+
75
+ # # ======================================================
76
+ # # 2. VISUAL SEARCH (QDRANT 1.16.1 – DEDUPLICATED)
77
+ # # ======================================================
78
+ # @router.post("/visual/search")
79
+ # async def search_visual_products(
80
+ # file: UploadFile = File(...),
81
+ # db: AsyncSession = Depends(get_db),
82
+ # current_user: User = Depends(get_current_user)
83
+ # ):
84
+ # """
85
+ # Image → Embedding → Qdrant query_points → Unique Results
86
+ # """
87
+
88
+ # # ----------------------------------
89
+ # # 1. Load Qdrant Integration
90
+ # # ----------------------------------
91
+ # stmt = select(UserIntegration).where(
92
+ # UserIntegration.user_id == str(current_user.id),
93
+ # UserIntegration.provider == "qdrant",
94
+ # UserIntegration.is_active == True
95
+ # )
96
+
97
+ # result = await db.execute(stmt)
98
+ # integration = result.scalars().first()
99
+
100
+ # if not integration:
101
+ # raise HTTPException(
102
+ # status_code=400,
103
+ # detail="Qdrant integration not found."
104
+ # )
105
+
106
+ # try:
107
+ # creds = json.loads(integration.credentials)
108
+ # qdrant_url = creds["url"]
109
+ # qdrant_key = creds["api_key"]
110
+ # collection_name = "visual_search_products"
111
+ # except Exception:
112
+ # raise HTTPException(
113
+ # status_code=500,
114
+ # detail="Invalid Qdrant credentials format."
115
+ # )
116
+
117
+ # # ----------------------------------
118
+ # # 2. Image → Vector
119
+ # # ----------------------------------
120
+ # try:
121
+ # image_bytes = await file.read()
122
+ # vector = get_image_embedding(image_bytes)
123
+
124
+ # if not vector:
125
+ # raise ValueError("Empty embedding returned")
126
+ # except Exception as e:
127
+ # raise HTTPException(
128
+ # status_code=400,
129
+ # detail=f"Image processing failed: {e}"
130
+ # )
131
+
132
+ # # ----------------------------------
133
+ # # 3. Qdrant Search (query_points)
134
+ # # ----------------------------------
135
+ # try:
136
+ # def run_search():
137
+ # client = QdrantClient(
138
+ # url=qdrant_url,
139
+ # api_key=qdrant_key
140
+ # )
141
+
142
+ # # NOTE: Limit increased to 25 to ensure we have enough results
143
+ # # after removing duplicates (variants with same image).
144
+ # return client.query_points(
145
+ # collection_name=collection_name,
146
+ # query=vector,
147
+ # limit=25,
148
+ # with_payload=True,
149
+ # query_filter=models.Filter(
150
+ # must=[
151
+ # models.FieldCondition(
152
+ # key="user_id",
153
+ # match=models.MatchValue(
154
+ # value=str(current_user.id)
155
+ # )
156
+ # )
157
+ # ]
158
+ # )
159
+ # )
160
+
161
+ # # Execute search in thread
162
+ # search_response = await asyncio.to_thread(run_search)
163
+
164
+ # # Get points from response object
165
+ # hits = search_response.points
166
+
167
+ # # ----------------------------------
168
+ # # 4. Format & Remove Duplicates
169
+ # # ----------------------------------
170
+ # results = []
171
+ # seen_products = set() # To track unique product IDs
172
+
173
+ # for hit in hits:
174
+ # if hit.score < 0.50:
175
+ # continue
176
+
177
+ # payload = hit.payload or {}
178
+ # product_id = payload.get("product_id")
179
+
180
+ # # ✅ DUPLICATE CHECK:
181
+ # # Agar ye product ID pehle aa chuka hai (higher score ke sath),
182
+ # # toh is wale ko skip karo.
183
+ # if product_id in seen_products:
184
+ # continue
185
+
186
+ # seen_products.add(product_id)
187
+
188
+ # results.append({
189
+ # "product_id": product_id,
190
+ # "slug": payload.get("slug"),
191
+ # "image_path": payload.get("image_url"),
192
+ # "similarity": hit.score
193
+ # })
194
+
195
+ # # Optional: Limit final output to top 10 unique products
196
+ # if len(results) >= 10:
197
+ # break
198
+
199
+ # return {"results": results}
200
+
201
+ # except Exception as e:
202
+ # print(f"❌ Visual Search Failed: {e}")
203
+
204
+ # msg = str(e)
205
+ # if "dimension" in msg.lower():
206
+ # msg = "Vector dimension mismatch. Please re-run Visual Sync."
207
+ # if "not found" in msg.lower():
208
+ # msg = "Visual search collection not found. Run Sync first."
209
+
210
+ # raise HTTPException(status_code=500, detail=msg)
211
+ import json
212
+ import asyncio
213
+ from fastapi import (
214
+ APIRouter,
215
+ Depends,
216
+ UploadFile,
217
+ File,
218
+ HTTPException,
219
+ BackgroundTasks,
220
+ Request, # <--- NEW: Request object for headers/origin check
221
+ status
222
+ )
223
+ from sqlalchemy.ext.asyncio import AsyncSession
224
+ from sqlalchemy.future import select
225
+ from qdrant_client import QdrantClient
226
+ from qdrant_client.http import models
227
+
228
+ # =========================
229
+ # Auth & DB Imports
230
+ # =========================
231
+ # 👇 Change: Humne naya auth method import kiya
232
+ from backend.src.api.routes.deps import get_current_user, get_current_user_by_api_key
233
+ from backend.src.db.session import get_db, AsyncSessionLocal
234
+ from backend.src.models.user import User
235
+ from backend.src.models.integration import UserIntegration
236
+ from backend.src.models.ingestion import IngestionJob, JobStatus
237
+
238
+ # =========================
239
+ # Visual Services
240
+ # =========================
241
+ from backend.src.services.visual.engine import get_image_embedding
242
+ from backend.src.services.visual.agent import run_visual_sync
243
+
244
+ router = APIRouter()
245
+
246
+ # ======================================================
247
+ # HELPER: DOMAIN LOCK SECURITY 🔐
248
+ # ======================================================
249
+ def check_domain_authorization(user: User, request: Request):
250
+ """
251
+ Check if the request is coming from an allowed domain.
252
+ Logic copied from chat.py for consistency.
253
+ """
254
+ # 1. Browser headers check karein
255
+ client_origin = request.headers.get("origin") or request.headers.get("referer") or ""
256
+
257
+ # 2. Agar user ne "*" set kiya hai, to sab allow hai
258
+ if user.allowed_domains == "*":
259
+ return True
260
+
261
+ # 3. Allowed domains ki list banao
262
+ allowed = [d.strip() for d in user.allowed_domains.split(",")]
263
+
264
+ # 4. Check karo ke origin match karta hai ya nahi
265
+ is_authorized = any(domain in client_origin for domain in allowed)
266
+
267
+ if not is_authorized:
268
+ print(f"🚫 [Visual Security] Blocked unauthorized domain: {client_origin}")
269
+ raise HTTPException(
270
+ status_code=status.HTTP_403_FORBIDDEN,
271
+ detail="Domain not authorized to use this API."
272
+ )
273
+
274
+ # ======================================================
275
+ # 1. VISUAL SYNC (Dashboard Only - Uses JWT)
276
+ # ======================================================
277
+ @router.post("/visual/sync")
278
+ async def trigger_visual_sync(
279
+ background_tasks: BackgroundTasks,
280
+ db: AsyncSession = Depends(get_db),
281
+ # NOTE: Sync humesha Dashboard se hota hai, isliye JWT (get_current_user) rakha hai.
282
+ current_user: User = Depends(get_current_user)
283
+ ):
284
+ try:
285
+ job = IngestionJob(
286
+ session_id=f"visual_sync_{current_user.id}",
287
+ ingestion_type="visual_sync",
288
+ source_name="Store Integration (Visual)",
289
+ status=JobStatus.PENDING,
290
+ total_items=0,
291
+ items_processed=0
292
+ )
293
+
294
+ db.add(job)
295
+ await db.commit()
296
+ await db.refresh(job)
297
+
298
+ background_tasks.add_task(
299
+ run_visual_sync,
300
+ str(current_user.id),
301
+ job.id,
302
+ AsyncSessionLocal
303
+ )
304
+
305
+ return {
306
+ "status": "processing",
307
+ "message": "Visual Sync started successfully.",
308
+ "job_id": job.id
309
+ }
310
+
311
+ except Exception as e:
312
+ print(f"❌ Visual Sync Failed: {e}")
313
+ raise HTTPException(status_code=500, detail=str(e))
314
+
315
+
316
+ # ======================================================
317
+ # 2. VISUAL SEARCH (Public Widget - Uses API Key + Domain Lock)
318
+ # ======================================================
319
+ @router.post("/visual/search")
320
+ async def search_visual_products(
321
+ request: Request, # <--- Browser Request Access
322
+ file: UploadFile = File(...),
323
+ db: AsyncSession = Depends(get_db),
324
+ # 🔥 CHANGE: Ab ye API Key se authenticate hoga (Widget Friendly)
325
+ current_user: User = Depends(get_current_user_by_api_key)
326
+ ):
327
+ """
328
+ Image → Embedding → Qdrant query_points → Unique Results
329
+ Secured by API Key & Domain Lock.
330
+ """
331
+
332
+ # 🔒 1. Domain Security Check
333
+ check_domain_authorization(current_user, request)
334
+
335
+ # ----------------------------------
336
+ # 2. Load Qdrant Integration
337
+ # ----------------------------------
338
+ stmt = select(UserIntegration).where(
339
+ UserIntegration.user_id == str(current_user.id),
340
+ UserIntegration.provider == "qdrant",
341
+ UserIntegration.is_active == True
342
+ )
343
+
344
+ result = await db.execute(stmt)
345
+ integration = result.scalars().first()
346
+
347
+ if not integration:
348
+ raise HTTPException(
349
+ status_code=400,
350
+ detail="Qdrant integration not found."
351
+ )
352
+
353
+ try:
354
+ creds = json.loads(integration.credentials)
355
+ qdrant_url = creds["url"]
356
+ qdrant_key = creds["api_key"]
357
+ # 🔥 CHANGE: Look for 'visual_collection_name' specifically
358
+ # This prevents conflict with Chat's 'collection_name'
359
+ collection_name = creds.get("visual_collection_name", "visual_search_products")
360
+ except Exception:
361
+ raise HTTPException(
362
+ status_code=500,
363
+ detail="Invalid Qdrant credentials format."
364
+ )
365
+
366
+ # ----------------------------------
367
+ # 3. Image → Vector
368
+ # ----------------------------------
369
+ try:
370
+ image_bytes = await file.read()
371
+ vector = get_image_embedding(image_bytes)
372
+
373
+ if not vector:
374
+ raise ValueError("Empty embedding returned")
375
+ except Exception as e:
376
+ raise HTTPException(
377
+ status_code=400,
378
+ detail=f"Image processing failed: {e}"
379
+ )
380
+
381
+ # ----------------------------------
382
+ # 4. Qdrant Search (query_points)
383
+ # ----------------------------------
384
+ try:
385
+ def run_search():
386
+ client = QdrantClient(
387
+ url=qdrant_url,
388
+ api_key=qdrant_key
389
+ )
390
+
391
+ # Limit 25 taake duplicates hatane ke baad bhi kafi results bachein
392
+ return client.query_points(
393
+ collection_name=collection_name,
394
+ query=vector,
395
+ limit=25,
396
+ with_payload=True,
397
+ query_filter=models.Filter(
398
+ must=[
399
+ models.FieldCondition(
400
+ key="user_id",
401
+ match=models.MatchValue(
402
+ value=str(current_user.id)
403
+ )
404
+ )
405
+ ]
406
+ )
407
+ )
408
+
409
+ # Execute search in thread
410
+ search_response = await asyncio.to_thread(run_search)
411
+
412
+ # Get points from response object
413
+ hits = search_response.points
414
+
415
+ # ----------------------------------
416
+ # 5. Format & Remove Duplicates
417
+ # ----------------------------------
418
+ results = []
419
+ seen_products = set() # To track unique product IDs
420
+
421
+ for hit in hits:
422
+ if hit.score < 0.50:
423
+ continue
424
+
425
+ payload = hit.payload or {}
426
+ product_id = payload.get("product_id")
427
+
428
+ # ✅ DUPLICATE CHECK
429
+ if product_id in seen_products:
430
+ continue
431
+
432
+ seen_products.add(product_id)
433
+
434
+ results.append({
435
+ "product_id": product_id,
436
+ "slug": payload.get("slug"),
437
+ "image_path": payload.get("image_url"),
438
+ "similarity": hit.score
439
+ })
440
+
441
+ # Optional: Limit final output to top 10 unique products
442
+ if len(results) >= 10:
443
+ break
444
+
445
+ return {"results": results}
446
+
447
+ except Exception as e:
448
+ print(f"❌ Visual Search Failed: {e}")
449
+
450
+ msg = str(e)
451
+ if "dimension" in msg.lower():
452
+ msg = "Vector dimension mismatch. Please re-run Visual Sync."
453
+ if "not found" in msg.lower():
454
+ msg = "Visual search collection not found. Run Sync first."
455
+
456
+ raise HTTPException(status_code=500, detail=msg)
backend/src/core/config.py CHANGED
@@ -1,3 +1,4 @@
 
1
  # --- EXTERNAL IMPORTS ---
2
  import os
3
  from pydantic_settings import BaseSettings, SettingsConfigDict
 
1
+ # backend/src/core/config.py
2
  # --- EXTERNAL IMPORTS ---
3
  import os
4
  from pydantic_settings import BaseSettings, SettingsConfigDict
backend/src/db/session.py CHANGED
@@ -1,23 +1,31 @@
1
- # --- EXTERNAL IMPORTS ---
2
  from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
3
- from sqlalchemy import create_engine
4
  from backend.src.core.config import settings
5
 
6
  # Connection Arguments
7
  connect_args = {}
 
 
 
 
 
 
 
 
 
 
8
  if "sqlite" in settings.DATABASE_URL:
9
  connect_args = {"check_same_thread": False}
10
 
11
- # --- ROBUST ENGINE CREATION (The Fix) ---
12
- # Ye settings Neon/Serverless ke liye best hain
13
  engine = create_async_engine(
14
  settings.DATABASE_URL,
15
  echo=False,
16
  connect_args=connect_args,
17
- pool_size=5, # 5 connections ka pool rakho
18
- max_overflow=10, # Agar zaroorat pade to 10 aur bana lo
19
- pool_recycle=300, # Har 5 minute (300s) mein purane connections ko refresh karo (Sleep issue fix)
20
- pool_pre_ping=True, # Har query se pehle check karo ke connection zinda hai ya nahi
 
21
  )
22
 
23
  # Session Maker
@@ -33,5 +41,15 @@ async def get_db():
33
  async with AsyncSessionLocal() as session:
34
  try:
35
  yield session
 
 
 
 
36
  finally:
37
- await session.close()
 
 
 
 
 
 
 
 
1
  from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
 
2
  from backend.src.core.config import settings
3
 
4
  # Connection Arguments
5
  connect_args = {}
6
+
7
+ # Agar Postgres hai, to command timeout badha do
8
+ if "postgresql" in settings.DATABASE_URL:
9
+ connect_args = {
10
+ "command_timeout": 60, # 60 seconds tak wait karega query ka
11
+ "server_settings": {
12
+ "jit": "off" # JIT compilation off karne se kabhi kabhi speed fast hoti hai
13
+ }
14
+ }
15
+
16
  if "sqlite" in settings.DATABASE_URL:
17
  connect_args = {"check_same_thread": False}
18
 
19
+ # --- ROBUST ENGINE CREATION ---
 
20
  engine = create_async_engine(
21
  settings.DATABASE_URL,
22
  echo=False,
23
  connect_args=connect_args,
24
+ pool_size=20, # Pool size badhaya (Load handle karne ke liye)
25
+ max_overflow=40, # Overflow badhaya
26
+ pool_recycle=300, # Refresh every 5 mins
27
+ pool_pre_ping=True, # Connection check before query
28
+ pool_timeout=60 # 🔥 Timeout aur badha diya (30s -> 60s)
29
  )
30
 
31
  # Session Maker
 
41
  async with AsyncSessionLocal() as session:
42
  try:
43
  yield session
44
+ except Exception:
45
+ # Agar koi logic error aaye to rollback karein
46
+ await session.rollback()
47
+ raise
48
  finally:
49
+ # 🔥 FIX: Graceful Cleanup
50
+ # Agar connection pehle hi close ho gaya ho (heavy load ki wajah se),
51
+ # to dobara close karne par crash na ho.
52
+ try:
53
+ await session.close()
54
+ except Exception:
55
+ pass
backend/src/init_db.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import asyncio
2
  from backend.src.db.session import engine
3
  from backend.src.db.base import Base
 
1
+ # backend/src/init_db.py
2
  import asyncio
3
  from backend.src.db.session import engine
4
  from backend.src.db.base import Base
backend/src/main.py CHANGED
@@ -1,13 +1,22 @@
 
1
  # --- EXTERNAL IMPORTS ---
2
  import os
 
 
3
  from fastapi import FastAPI
4
  from fastapi.staticfiles import StaticFiles # <--- New Import
5
  from fastapi.middleware.cors import CORSMiddleware
6
  from backend.src.core.config import settings
7
-
8
  # --- API Route Imports ---
9
  from backend.src.api.routes import chat, ingestion, auth, settings as settings_route
10
 
 
 
 
 
 
 
11
  # 1. App Initialize karein
12
  app = FastAPI(
13
  title=settings.PROJECT_NAME,
@@ -47,6 +56,7 @@ app.include_router(auth.router, prefix=settings.API_V1_STR, tags=["Authenticatio
47
  app.include_router(settings_route.router, prefix=settings.API_V1_STR, tags=["User Settings"])
48
  app.include_router(chat.router, prefix=settings.API_V1_STR, tags=["Chat"])
49
  app.include_router(ingestion.router, prefix=settings.API_V1_STR, tags=["Ingestion"])
 
50
 
51
  if __name__ == "__main__":
52
  import uvicorn
 
1
+ # backend/src/main.py
2
  # --- EXTERNAL IMPORTS ---
3
  import os
4
+ import asyncio
5
+ import sys # <--- 1. Import asyncio
6
  from fastapi import FastAPI
7
  from fastapi.staticfiles import StaticFiles # <--- New Import
8
  from fastapi.middleware.cors import CORSMiddleware
9
  from backend.src.core.config import settings
10
+ from backend.src.api.routes import visual
11
  # --- API Route Imports ---
12
  from backend.src.api.routes import chat, ingestion, auth, settings as settings_route
13
 
14
+ # ==========================================
15
+ # 🔥 WINDOWS FIX FOR DB TIMEOUTS 🔥
16
+ # ==========================================
17
+ if sys.platform.startswith("win"):
18
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
19
+
20
  # 1. App Initialize karein
21
  app = FastAPI(
22
  title=settings.PROJECT_NAME,
 
56
  app.include_router(settings_route.router, prefix=settings.API_V1_STR, tags=["User Settings"])
57
  app.include_router(chat.router, prefix=settings.API_V1_STR, tags=["Chat"])
58
  app.include_router(ingestion.router, prefix=settings.API_V1_STR, tags=["Ingestion"])
59
+ app.include_router(visual.router, prefix=settings.API_V1_STR, tags=["Visual Search"])
60
 
61
  if __name__ == "__main__":
62
  import uvicorn
backend/src/models/ingestion.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, JSON # <--- JSON import karein
2
  from sqlalchemy.sql import func
3
  import enum
 
1
+ # backend/src/models/ingestion.py
2
  from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, JSON # <--- JSON import karein
3
  from sqlalchemy.sql import func
4
  import enum
backend/src/models/integration.py CHANGED
@@ -1,4 +1,4 @@
1
-
2
  from sqlalchemy import Column, Integer, String, Text, Boolean, JSON, DateTime
3
  from sqlalchemy.sql import func
4
  from backend.src.db.base import Base
 
1
+ # backend/src/models/integration.py
2
  from sqlalchemy import Column, Integer, String, Text, Boolean, JSON, DateTime
3
  from sqlalchemy.sql import func
4
  from backend.src.db.base import Base
backend/src/models/user.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text
2
  from sqlalchemy.sql import func
3
  from backend.src.db.base import Base
 
1
+ # backend/src/models/user.py
2
  from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text
3
  from sqlalchemy.sql import func
4
  from backend.src.db.base import Base
backend/src/schemas/chat.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from pydantic import BaseModel
2
  from typing import Optional
3
 
 
1
+ # backend/src/schemas/chat.py
2
  from pydantic import BaseModel
3
  from typing import Optional
4
 
backend/src/services/chat_service.py CHANGED
@@ -1,4 +1,5 @@
1
 
 
2
  # import json
3
  # from sqlalchemy.ext.asyncio import AsyncSession
4
  # from sqlalchemy.future import select
@@ -6,7 +7,7 @@
6
  # # --- Model Imports ---
7
  # from backend.src.models.chat import ChatHistory
8
  # from backend.src.models.integration import UserIntegration
9
- # from backend.src.models.user import User # Added User model for Bot Persona
10
 
11
  # # --- Dynamic Factory & Tool Imports ---
12
  # from backend.src.services.llm.factory import get_llm_model
@@ -44,7 +45,6 @@
44
  # creds['provider'] = i.provider
45
  # creds['schema_map'] = i.schema_map if i.schema_map else {}
46
 
47
- # # --- STRICT CHECK ---
48
  # if i.profile_description:
49
  # creds['description'] = i.profile_description
50
 
@@ -74,7 +74,6 @@
74
  # async def get_bot_persona(user_id: str, db: AsyncSession):
75
  # """Fetches custom Bot Name and Instructions from User table."""
76
  # try:
77
- # # User ID ko int mein convert karke query karein
78
  # stmt = select(User).where(User.id == int(user_id))
79
  # result = await db.execute(stmt)
80
  # user = result.scalars().first()
@@ -88,17 +87,16 @@
88
  # print(f"⚠️ Error fetching persona: {e}")
89
  # pass
90
 
91
- # # Fallback Default Persona
92
  # return {"name": "OmniAgent", "instruction": "You are a helpful AI assistant."}
93
 
94
  # # ==========================================
95
- # # MAIN CHAT LOGIC
96
  # # ==========================================
97
  # async def process_chat(message: str, session_id: str, user_id: str, db: AsyncSession):
98
 
99
  # # 1. Fetch User Settings & Persona
100
  # user_settings = await get_user_integrations(user_id, db)
101
- # bot_persona = await get_bot_persona(user_id, db) # <--- Persona Load kiya
102
 
103
  # # 2. LLM Check
104
  # llm_creds = user_settings.get('groq') or user_settings.get('openai')
@@ -115,13 +113,13 @@
115
  # # 4. SEMANTIC DECISION (Router)
116
  # selected_provider = None
117
  # if tools_map:
118
- # router = SemanticRouter() # Singleton Instance
119
  # selected_provider = router.route(message, tools_map)
120
 
121
  # response_text = ""
122
  # provider_name = "general_chat"
123
 
124
- # # 5. Route to Winner
125
  # if selected_provider:
126
  # print(f"👉 [Router] Selected Tool: {selected_provider.upper()}")
127
  # try:
@@ -145,18 +143,16 @@
145
  # response_text = str(res.get('output', ''))
146
  # provider_name = "nosql_agent"
147
 
148
- # # Anti-Hallucination
149
  # if not response_text or "error" in response_text.lower():
150
- # print(f"⚠️ [Router] Tool {selected_provider} failed. Triggering Fallback.")
151
  # response_text = ""
152
 
153
  # except Exception as e:
154
- # print(f"❌ [Router] Execution Failed: {e}")
155
  # response_text = ""
156
 
157
- # # 6. Fallback / RAG (Using Custom Persona)
158
  # if not response_text:
159
- # print("👉 [Router] Fallback to RAG/General Chat...")
160
  # try:
161
  # llm = get_llm_model(credentials=llm_creds)
162
 
@@ -171,15 +167,26 @@
171
  # except Exception as e:
172
  # print(f"⚠️ RAG Warning: {e}")
173
 
174
- # # --- 🔥 DYNAMIC SYSTEM PROMPT ---
175
  # system_instruction = f"""
176
- # IDENTITY: You are '{bot_persona['name']}'.
177
- # MISSION: {bot_persona['instruction']}
178
-
 
 
 
 
 
 
 
 
 
 
 
179
  # CONTEXT FROM KNOWLEDGE BASE:
180
- # {context if context else "No specific documents found."}
181
-
182
- # Answer the user's question based on the context above or your general knowledge if permitted by your mission.
183
  # """
184
 
185
  # # History Load
@@ -189,7 +196,7 @@
189
  # formatted_history.append(HumanMessage(content=chat.human_message))
190
  # if chat.ai_message: formatted_history.append(AIMessage(content=chat.ai_message))
191
 
192
- # # LLM Call
193
  # prompt = ChatPromptTemplate.from_messages([
194
  # ("system", system_instruction),
195
  # MessagesPlaceholder(variable_name="chat_history"),
@@ -203,7 +210,7 @@
203
 
204
  # except Exception as e:
205
  # print(f"❌ Fallback Error: {e}")
206
- # response_text = "I am currently unable to process your request. Please check your AI configuration."
207
 
208
  # # 7. Save to DB
209
  # await save_chat_to_db(db, session_id, message, response_text, provider_name)
@@ -211,6 +218,7 @@
211
  import json
212
  from sqlalchemy.ext.asyncio import AsyncSession
213
  from sqlalchemy.future import select
 
214
 
215
  # --- Model Imports ---
216
  from backend.src.models.chat import ChatHistory
@@ -369,7 +377,28 @@ async def process_chat(message: str, session_id: str, user_id: str, db: AsyncSes
369
  if 'qdrant' in user_settings:
370
  try:
371
  vector_store = get_vector_store(credentials=user_settings['qdrant'])
372
- docs = await vector_store.asimilarity_search(message, k=3)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  if docs:
374
  context = "\n\n".join([d.page_content for d in docs])
375
  except Exception as e:
 
1
 
2
+ # # backend/src/services/chat_service.py
3
  # import json
4
  # from sqlalchemy.ext.asyncio import AsyncSession
5
  # from sqlalchemy.future import select
 
7
  # # --- Model Imports ---
8
  # from backend.src.models.chat import ChatHistory
9
  # from backend.src.models.integration import UserIntegration
10
+ # from backend.src.models.user import User
11
 
12
  # # --- Dynamic Factory & Tool Imports ---
13
  # from backend.src.services.llm.factory import get_llm_model
 
45
  # creds['provider'] = i.provider
46
  # creds['schema_map'] = i.schema_map if i.schema_map else {}
47
 
 
48
  # if i.profile_description:
49
  # creds['description'] = i.profile_description
50
 
 
74
  # async def get_bot_persona(user_id: str, db: AsyncSession):
75
  # """Fetches custom Bot Name and Instructions from User table."""
76
  # try:
 
77
  # stmt = select(User).where(User.id == int(user_id))
78
  # result = await db.execute(stmt)
79
  # user = result.scalars().first()
 
87
  # print(f"⚠️ Error fetching persona: {e}")
88
  # pass
89
 
 
90
  # return {"name": "OmniAgent", "instruction": "You are a helpful AI assistant."}
91
 
92
  # # ==========================================
93
+ # # MAIN CHAT LOGIC (Ultra-Strict Isolated Mode)
94
  # # ==========================================
95
  # async def process_chat(message: str, session_id: str, user_id: str, db: AsyncSession):
96
 
97
  # # 1. Fetch User Settings & Persona
98
  # user_settings = await get_user_integrations(user_id, db)
99
+ # bot_persona = await get_bot_persona(user_id, db)
100
 
101
  # # 2. LLM Check
102
  # llm_creds = user_settings.get('groq') or user_settings.get('openai')
 
113
  # # 4. SEMANTIC DECISION (Router)
114
  # selected_provider = None
115
  # if tools_map:
116
+ # router = SemanticRouter()
117
  # selected_provider = router.route(message, tools_map)
118
 
119
  # response_text = ""
120
  # provider_name = "general_chat"
121
 
122
+ # # 5. Route to Winner (Agent Execution)
123
  # if selected_provider:
124
  # print(f"👉 [Router] Selected Tool: {selected_provider.upper()}")
125
  # try:
 
143
  # response_text = str(res.get('output', ''))
144
  # provider_name = "nosql_agent"
145
 
 
146
  # if not response_text or "error" in response_text.lower():
 
147
  # response_text = ""
148
 
149
  # except Exception as e:
150
+ # print(f"❌ Agent Execution Failed: {e}")
151
  # response_text = ""
152
 
153
+ # # 6. Fallback / RAG (ULTRA-STRICT MODE 🛡️)
154
  # if not response_text:
155
+ # print("👉 [Router] Executing Strict RAG Fallback...")
156
  # try:
157
  # llm = get_llm_model(credentials=llm_creds)
158
 
 
167
  # except Exception as e:
168
  # print(f"⚠️ RAG Warning: {e}")
169
 
170
+ # # --- 🔥 THE ULTRA-STRICT SYSTEM PROMPT ---
171
  # system_instruction = f"""
172
+ # SYSTEM IDENTITY:
173
+ # You are the '{bot_persona['name']}'. You are a 'Knowledge-Isolated' AI Assistant for this specific platform.
174
+
175
+ # CORE MISSION:
176
+ # Your ONLY source of truth is the 'CONTEXT FROM KNOWLEDGE BASE' provided below.
177
+ # You must ignore ALL of your internal pre-trained general knowledge about the world, geography, famous people, or general facts.
178
+
179
+ # STRICT OPERATING RULES:
180
+ # 1. MANDATORY REFUSAL: If the user's question cannot be answered using ONLY the provided context, you MUST exactly say: "I apologize, but I am only authorized to provide information based on the provided database. This specific information is not currently available in my knowledge base."
181
+ # 2. NO HALLUCINATION: Never attempt to be helpful using outside information. If a fact (like 'Japan's location') is not in the context, you do NOT know it.
182
+ # 3. CONTEXT-ONLY: Your existence is bounded by the data below. If the data is empty, you cannot answer anything except greetings.
183
+ # 4. GREETINGS: You may respond to 'Hi' or 'Hello' by briefly identifying yourself as '{bot_persona['name']}' and asking what data the user is looking for.
184
+ # 5. PROHIBITED TOPICS: Do not discuss any topic that is not present in the provided context.
185
+
186
  # CONTEXT FROM KNOWLEDGE BASE:
187
+ # ---------------------------
188
+ # {context if context else "THE DATABASE IS CURRENTLY EMPTY. DO NOT PROVIDE ANY INFORMATION."}
189
+ # ---------------------------
190
  # """
191
 
192
  # # History Load
 
196
  # formatted_history.append(HumanMessage(content=chat.human_message))
197
  # if chat.ai_message: formatted_history.append(AIMessage(content=chat.ai_message))
198
 
199
+ # # LLM Chain Setup
200
  # prompt = ChatPromptTemplate.from_messages([
201
  # ("system", system_instruction),
202
  # MessagesPlaceholder(variable_name="chat_history"),
 
210
 
211
  # except Exception as e:
212
  # print(f"❌ Fallback Error: {e}")
213
+ # response_text = "I apologize, but I am currently unable to process your request due to a system error."
214
 
215
  # # 7. Save to DB
216
  # await save_chat_to_db(db, session_id, message, response_text, provider_name)
 
218
  import json
219
  from sqlalchemy.ext.asyncio import AsyncSession
220
  from sqlalchemy.future import select
221
+ from qdrant_client.http import models # <--- NEW IMPORT (Filter ke liye)
222
 
223
  # --- Model Imports ---
224
  from backend.src.models.chat import ChatHistory
 
377
  if 'qdrant' in user_settings:
378
  try:
379
  vector_store = get_vector_store(credentials=user_settings['qdrant'])
380
+
381
+ # 🔥 SECURITY FIX: FILTER BY USER_ID 🔥
382
+ # Hum ensure kar rahe hain ke LangChain sirf ISI USER ka data uthaye.
383
+ # QdrantAdapter mein humne metadata_payload_key="metadata" set kiya tha.
384
+ # Isliye key "metadata.user_id" hogi.
385
+
386
+ user_filter = models.Filter(
387
+ must=[
388
+ models.FieldCondition(
389
+ key="metadata.user_id",
390
+ match=models.MatchValue(value=str(user_id))
391
+ )
392
+ ]
393
+ )
394
+
395
+ # Ab search mein filter pass karein
396
+ docs = await vector_store.asimilarity_search(
397
+ message,
398
+ k=3,
399
+ filter=user_filter
400
+ )
401
+
402
  if docs:
403
  context = "\n\n".join([d.page_content for d in docs])
404
  except Exception as e:
backend/src/services/connectors/base.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from abc import ABC, abstractmethod
2
  from typing import List, Dict, Any, Optional
3
 
 
1
+ # backend/src/services/connectors/base.py
2
  from abc import ABC, abstractmethod
3
  from typing import List, Dict, Any, Optional
4
 
backend/src/services/connectors/cms_base.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from abc import ABC, abstractmethod
2
  from typing import Dict, Any, List
3
 
 
1
+ # backend/src/services/connectors/cms_base.py
2
  from abc import ABC, abstractmethod
3
  from typing import Dict, Any, List
4
 
backend/src/services/connectors/mongo_connector.py CHANGED
@@ -1,4 +1,4 @@
1
-
2
  import pymongo
3
  from typing import List, Dict, Any, Optional
4
  from backend.src.services.connectors.base import NoSQLConnector
 
1
+ # backend/src/services/connectors/mongo_connector.py
2
  import pymongo
3
  from typing import List, Dict, Any, Optional
4
  from backend.src.services.connectors.base import NoSQLConnector
backend/src/services/connectors/sanity_connector.py CHANGED
@@ -1,4 +1,4 @@
1
-
2
  import requests
3
  import json
4
  from urllib.parse import quote
 
1
+ # backend/src/services/connectors/sanity_connector.py
2
  import requests
3
  import json
4
  from urllib.parse import quote
backend/src/services/connectors/shopify_connector.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/src/services/connectors/shopify_connector.py
2
+
3
+ import shopify
4
+ import time
5
+ from typing import List, Dict, Any
6
+
7
+ class ShopifyConnector:
8
+ def __init__(self, credentials: Dict[str, str]):
9
+ self.shop_url = credentials.get("shop_url")
10
+ self.access_token = credentials.get("access_token")
11
+ self.api_version = credentials.get("api_version", "2024-01") # Default stable version
12
+
13
+ if not self.shop_url or not self.access_token:
14
+ raise ValueError("Shopify credentials (shop_url, access_token) are required.")
15
+
16
+ # Session Setup
17
+ self.session = shopify.Session(self.shop_url, self.api_version, self.access_token)
18
+
19
+ def fetch_all_products(self) -> List[Dict[str, Any]]:
20
+ """
21
+ Shopify Admin API se saare products aur unki images fetch karta hai.
22
+ Pagination handle karta hai taake saara data aaye.
23
+ """
24
+ print(f"🛍️ [Shopify] Connecting to {self.shop_url}...")
25
+ shopify.ShopifyResource.activate_session(self.session)
26
+
27
+ product_list = []
28
+
29
+ try:
30
+ # Pehla page fetch karein (Limit 250 max hai)
31
+ page = shopify.Product.find(limit=250)
32
+
33
+ while page:
34
+ for product in page:
35
+ if not product.images:
36
+ continue
37
+
38
+ # Har image ko process karein
39
+ for image in product.images:
40
+ product_list.append({
41
+ # Unique ID: ProductID_ImageID
42
+ "id": f"{product.id}_{image.id}",
43
+ "image_path": image.src,
44
+ # Slug (Handle) taake user click karke product par ja sake
45
+ "slug": product.handle,
46
+ "product_id": str(product.id)
47
+ })
48
+
49
+ # Agla page check karein
50
+ if page.has_next_page():
51
+ time.sleep(0.5) # Rate limit se bachne ke liye thoda wait
52
+ page = page.next_page()
53
+ else:
54
+ break
55
+
56
+ print(f"✅ [Shopify] Fetched {len(product_list)} images successfully.")
57
+ return product_list
58
+
59
+ except Exception as e:
60
+ print(f"❌ [Shopify] Error fetching products: {e}")
61
+ return []
62
+
63
+ finally:
64
+ # Session close karna zaroori hai
65
+ shopify.ShopifyResource.clear_session()
66
+
67
+ # Wrapper function jo Agent use karega
68
+ def fetch_all_products(credentials: dict):
69
+ connector = ShopifyConnector(credentials)
70
+ return connector.fetch_all_products()
backend/src/services/connectors/woocommerce_connector.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/src/services/connectors/woocommerce_connector.py
2
+
3
+ from woocommerce import API
4
+ from typing import List, Dict, Any
5
+
6
+ class WooCommerceConnector:
7
+ def __init__(self, credentials: Dict[str, str]):
8
+ self.url = credentials.get("website_url") or credentials.get("url")
9
+ self.consumer_key = credentials.get("consumer_key")
10
+ self.consumer_secret = credentials.get("consumer_secret")
11
+
12
+ if not all([self.url, self.consumer_key, self.consumer_secret]):
13
+ raise ValueError("WooCommerce credentials (url, consumer_key, consumer_secret) are required.")
14
+
15
+ # API Setup
16
+ self.wcapi = API(
17
+ url=self.url,
18
+ consumer_key=self.consumer_key,
19
+ consumer_secret=self.consumer_secret,
20
+ version="wc/v3",
21
+ timeout=20 # Thoda zyada time dete hain slow WP sites ke liye
22
+ )
23
+
24
+ def fetch_all_products(self) -> List[Dict[str, Any]]:
25
+ """
26
+ WooCommerce API se paginated products fetch karta hai.
27
+ """
28
+ print(f"🛒 [WooCommerce] Connecting to {self.url}...")
29
+
30
+ product_list = []
31
+ page = 1
32
+ per_page = 50 # Reasonable chunk size
33
+
34
+ try:
35
+ while True:
36
+ # API Call
37
+ response = self.wcapi.get("products", params={"per_page": per_page, "page": page, "status": "publish"})
38
+
39
+ if response.status_code != 200:
40
+ print(f"⚠️ [WooCommerce] Error on page {page}: {response.status_code} - {response.text}")
41
+ break
42
+
43
+ products = response.json()
44
+
45
+ # Agar products khatam ho gaye, to loop roko
46
+ if not products:
47
+ break
48
+
49
+ for product in products:
50
+ # Agar image nahi hai to skip karo
51
+ if not product.get("images"):
52
+ continue
53
+
54
+ for image in product["images"]:
55
+ product_list.append({
56
+ # Unique ID: ProductID_ImageID
57
+ "id": f"{product['id']}_{image['id']}",
58
+ "image_path": image["src"],
59
+ "slug": product["slug"], # Product URL slug
60
+ "product_id": str(product['id'])
61
+ })
62
+
63
+ print(f" -> Page {page} fetched ({len(products)} items)")
64
+ page += 1
65
+
66
+ print(f"✅ [WooCommerce] Fetched {len(product_list)} images successfully.")
67
+ return product_list
68
+
69
+ except Exception as e:
70
+ print(f"❌ [WooCommerce] Connection Error: {e}")
71
+ return []
72
+
73
+ # Wrapper function for Agent
74
+ def fetch_all_products(credentials: dict):
75
+ connector = WooCommerceConnector(credentials)
76
+ return connector.fetch_all_products()
backend/src/services/ingestion/crawler.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import asyncio
2
  import requests
3
  import json # Credentials decode karne ke liye
 
1
+ # backend/src/services/ingestion/crawler.py
2
  import asyncio
3
  import requests
4
  import json # Credentials decode karne ke liye
backend/src/services/ingestion/file_processor.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import os
2
  import asyncio
3
  import json
 
1
+ # backend/src/services/ingestion/file_processor.py
2
  import os
3
  import asyncio
4
  import json
backend/src/services/ingestion/guardrail_factory.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from sentence_transformers import CrossEncoder
2
  import asyncio
3
  import os
 
1
+ # backend/src/services/ingestion/guardrail_factory.py
2
  from sentence_transformers import CrossEncoder
3
  import asyncio
4
  import os
backend/src/services/ingestion/web_processor.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import asyncio
2
  import json
3
  from sqlalchemy.ext.asyncio import AsyncSession
 
1
+ # backend/src/services/ingestion/web_processor.py
2
  import asyncio
3
  import json
4
  from sqlalchemy.ext.asyncio import AsyncSession
backend/src/services/ingestion/zip_processor.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import zipfile
2
  import os
3
  import shutil
 
1
+ # backend/src/services/ingestion/zip_processor.py
2
  import zipfile
3
  import os
4
  import shutil
backend/src/services/llm/factory.py CHANGED
@@ -1,4 +1,4 @@
1
-
2
  from langchain_google_genai import ChatGoogleGenerativeAI
3
  from langchain_openai import ChatOpenAI
4
  from backend.src.core.config import settings
 
1
+ # backend/src/services/llm/factory.py
2
  from langchain_google_genai import ChatGoogleGenerativeAI
3
  from langchain_openai import ChatOpenAI
4
  from backend.src.core.config import settings
backend/src/services/routing/semantic_router.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from sentence_transformers import SentenceTransformer
2
  from sklearn.metrics.pairwise import cosine_similarity
3
  import numpy as np
 
1
+ # backend/src/services/routing/semantic_router.py
2
  from sentence_transformers import SentenceTransformer
3
  from sklearn.metrics.pairwise import cosine_similarity
4
  import numpy as np
backend/src/services/security/pii_scrubber.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import re
2
  from typing import Tuple
3
 
 
1
+ # backend/src/services/security/pii_scrubber.py
2
  import re
3
  from typing import Tuple
4
 
backend/src/services/tools/cms_agent.py CHANGED
@@ -1,4 +1,4 @@
1
-
2
  import json
3
  from langchain.agents import create_agent
4
  from backend.src.services.llm.factory import get_llm_model
 
1
+ # backend/src/services/tools/cms_agent.py
2
  import json
3
  from langchain.agents import create_agent
4
  from backend.src.services.llm.factory import get_llm_model
backend/src/services/tools/cms_tool.py CHANGED
@@ -1,4 +1,4 @@
1
-
2
  import json
3
  import ast
4
  from typing import Type
 
1
+ # backend/src/services/tools/cms_tool.py
2
  import json
3
  import ast
4
  from typing import Type
backend/src/services/tools/nosql_agent.py CHANGED
@@ -1,4 +1,4 @@
1
-
2
  from langchain.agents import create_agent
3
  from backend.src.services.llm.factory import get_llm_model
4
  from backend.src.services.tools.nosql_tool import NoSQLQueryTool
 
1
+ # backend/src/services/tools/nosql_agent.py
2
  from langchain.agents import create_agent
3
  from backend.src.services.llm.factory import get_llm_model
4
  from backend.src.services.tools.nosql_tool import NoSQLQueryTool
backend/src/services/tools/nosql_tool.py CHANGED
@@ -1,4 +1,4 @@
1
-
2
  import json
3
  import asyncio
4
  from typing import Type
 
1
+ # backend/src/services/tools/nosql_tool.py
2
  import json
3
  import asyncio
4
  from typing import Type
backend/src/services/tools/secure_agent.py CHANGED
@@ -1,4 +1,4 @@
1
-
2
  from langchain.agents import create_agent
3
  from backend.src.services.llm.factory import get_llm_model
4
  from backend.src.services.tools.sql_tool import get_sql_toolkit # Updated Import
 
1
+ # backend/src/services/tools/secure_agent.py
2
  from langchain.agents import create_agent
3
  from backend.src.services.llm.factory import get_llm_model
4
  from backend.src.services.tools.sql_tool import get_sql_toolkit # Updated Import
backend/src/services/tools/sql_tool.py CHANGED
@@ -1,4 +1,4 @@
1
-
2
  from langchain_community.utilities import SQLDatabase
3
  from langchain_community.agent_toolkits import SQLDatabaseToolkit
4
  from backend.src.services.llm.factory import get_llm_model
 
1
+ # backend/src/services/tools/sql_tool.py
2
  from langchain_community.utilities import SQLDatabase
3
  from langchain_community.agent_toolkits import SQLDatabaseToolkit
4
  from backend.src.services.llm.factory import get_llm_model
backend/src/services/visual/agent.py ADDED
@@ -0,0 +1,524 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # import asyncio
2
+ # import json
3
+ # import requests
4
+ # import concurrent.futures
5
+ # from uuid import uuid4
6
+ # from sqlalchemy.future import select
7
+ # from qdrant_client.http import models
8
+
9
+ # # Internal Modules
10
+ # from backend.src.models.integration import UserIntegration
11
+ # from backend.src.models.ingestion import IngestionJob, JobStatus
12
+ # from backend.src.services.vector_store.qdrant_adapter import get_vector_store
13
+ # from backend.src.services.visual.engine import get_image_embedding
14
+
15
+ # # Connectors
16
+ # from backend.src.services.connectors.sanity_connector import SanityConnector
17
+ # from backend.src.services.connectors.shopify_connector import fetch_all_products as fetch_shopify
18
+ # from backend.src.services.connectors.woocommerce_connector import fetch_all_products as fetch_woo
19
+
20
+ # # --- OPTIMIZATION CONFIG ---
21
+ # BATCH_SIZE = 100
22
+ # MAX_WORKERS = 20
23
+
24
+ # # --- 🔥 SAFE LOGGING HELPER ---
25
+ # async def update_job_safe(db_factory, job_id: int, status: str, processed=0, total=0, error=None):
26
+ # try:
27
+ # async with db_factory() as db:
28
+ # result = await db.execute(select(IngestionJob).where(IngestionJob.id == job_id))
29
+ # job = result.scalars().first()
30
+ # if job:
31
+ # job.status = status
32
+ # job.items_processed = processed
33
+ # job.total_items = total
34
+ # if error:
35
+ # job.error_message = str(error)
36
+ # await db.commit()
37
+ # except Exception as e:
38
+ # print(f"⚠️ Status Update Failed: {e}")
39
+
40
+ # async def fetch_products_from_source(provider: str, credentials: dict):
41
+ # products = []
42
+ # print(f"🔄 [Visual Agent] Fetching products from {provider}...")
43
+ # try:
44
+ # if provider == 'sanity':
45
+ # connector = SanityConnector(credentials)
46
+ # query = """*[_type == "product" && defined(variants)]{
47
+ # _id, "slug": slug.current, "variants": variants[]{ _key, images[]{ asset->{url} } }
48
+ # }"""
49
+ # raw_data = connector.execute_query(query)
50
+ # for item in raw_data:
51
+ # if not item.get('variants'): continue
52
+ # for variant in item['variants']:
53
+ # if not variant.get('images'): continue
54
+ # for img in variant['images']:
55
+ # if img.get('asset'):
56
+ # products.append({
57
+ # "id": f"{item['_id']}_{variant['_key']}",
58
+ # "image_path": img['asset']['url'],
59
+ # "slug": item.get('slug'),
60
+ # "product_id": item['_id']
61
+ # })
62
+ # elif provider == 'shopify':
63
+ # products = await asyncio.to_thread(fetch_shopify, credentials)
64
+ # elif provider == 'woocommerce':
65
+ # products = await asyncio.to_thread(fetch_woo, credentials)
66
+ # return products
67
+ # except Exception as e:
68
+ # print(f"❌ Fetch Error: {e}")
69
+ # return []
70
+
71
+ # def download_and_vectorize(product):
72
+ # # Ensure we use the correct key for image path
73
+ # image_url = product.get('image_path') or product.get('image_url')
74
+
75
+ # if not image_url:
76
+ # return None
77
+
78
+ # try:
79
+ # response = requests.get(image_url, timeout=5)
80
+ # if response.status_code != 200: return None
81
+ # image_bytes = response.content
82
+ # vector = get_image_embedding(image_bytes)
83
+ # if not vector: return None
84
+ # return {"product": product, "vector": vector}
85
+ # except Exception:
86
+ # return None
87
+
88
+ # async def run_visual_sync(user_id: str, job_id: int, db_factory):
89
+ # """
90
+ # High Performance Sync: Uses ThreadPool for parallel processing.
91
+ # """
92
+ # print(f"🚀 [Visual Agent] Starting Optimized Sync Job {job_id} for User: {user_id}")
93
+
94
+ # try:
95
+ # await update_job_safe(db_factory, job_id, JobStatus.PROCESSING)
96
+
97
+ # # 1. Credentials Fetch
98
+ # async with db_factory() as db:
99
+ # stmt = select(UserIntegration).where(
100
+ # UserIntegration.user_id == str(user_id),
101
+ # UserIntegration.is_active == True
102
+ # )
103
+ # result = await db.execute(stmt)
104
+ # integrations = result.scalars().all()
105
+
106
+ # qdrant_config = None
107
+ # store_config = None
108
+ # store_provider = None
109
+
110
+ # for i in integrations:
111
+ # if i.provider == 'qdrant':
112
+ # qdrant_config = json.loads(i.credentials)
113
+ # elif i.provider in ['sanity', 'shopify', 'woocommerce']:
114
+ # store_config = json.loads(i.credentials)
115
+ # store_provider = i.provider
116
+
117
+ # if not qdrant_config or not store_config:
118
+ # await update_job_safe(db_factory, job_id, JobStatus.FAILED, error="Missing Database or Store connection.")
119
+ # return
120
+
121
+ # # 2. Connect Qdrant & Setup Collection
122
+ # vector_store = get_vector_store(credentials=qdrant_config)
123
+ # collection_name = "visual_search_products"
124
+
125
+ # # Reset Collection
126
+ # try:
127
+ # vector_store.client.delete_collection(collection_name)
128
+ # except: pass
129
+
130
+ # vector_store.client.create_collection(
131
+ # collection_name=collection_name,
132
+ # vectors_config=models.VectorParams(size=2048, distance=models.Distance.COSINE)
133
+ # )
134
+
135
+ # # ✅ FIXED: Create Payload Index for 'user_id'
136
+ # # Ye zaroori hai taake Qdrant filter query allow kare
137
+ # print(f"🛠️ [Visual Agent] Creating index for user_id on {collection_name}...")
138
+ # vector_store.client.create_payload_index(
139
+ # collection_name=collection_name,
140
+ # field_name="user_id",
141
+ # field_schema=models.PayloadSchemaType.KEYWORD
142
+ # )
143
+
144
+ # # 3. Fetch Products
145
+ # products = await fetch_products_from_source(store_provider, store_config)
146
+ # total_products = len(products)
147
+ # await update_job_safe(db_factory, job_id, JobStatus.PROCESSING, total=total_products)
148
+
149
+ # if not products:
150
+ # await update_job_safe(db_factory, job_id, JobStatus.COMPLETED, error="No products found.")
151
+ # return
152
+
153
+ # print(f"⚡ Processing {total_products} images in batches of {BATCH_SIZE}...")
154
+
155
+ # # 4. OPTIMIZED BATCH PROCESSING
156
+ # processed_count = 0
157
+ # loop = asyncio.get_running_loop()
158
+
159
+ # for i in range(0, total_products, BATCH_SIZE):
160
+ # batch = products[i : i + BATCH_SIZE]
161
+ # points = []
162
+
163
+ # with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
164
+ # futures = [
165
+ # loop.run_in_executor(executor, download_and_vectorize, item)
166
+ # for item in batch
167
+ # ]
168
+ # results = await asyncio.gather(*futures)
169
+
170
+ # for res in results:
171
+ # if res:
172
+ # prod = res['product']
173
+ # # Use .get() to avoid KeyError if keys differ across providers
174
+ # img_url = prod.get('image_path') or prod.get('image_url')
175
+
176
+ # points.append(models.PointStruct(
177
+ # id=str(uuid4()),
178
+ # vector=res['vector'],
179
+ # payload={
180
+ # "product_id": prod.get('product_id'),
181
+ # "slug": prod.get('slug'),
182
+ # "image_url": img_url,
183
+ # "user_id": str(user_id),
184
+ # "source": store_provider
185
+ # }
186
+ # ))
187
+
188
+ # if points:
189
+ # await asyncio.to_thread(
190
+ # vector_store.client.upsert,
191
+ # collection_name=collection_name,
192
+ # points=points
193
+ # )
194
+ # processed_count += len(points)
195
+
196
+ # # --- SAFE STATUS UPDATE ---
197
+ # await update_job_safe(db_factory, job_id, JobStatus.PROCESSING, processed=processed_count, total=total_products)
198
+ # print(f" -> Batch {i//BATCH_SIZE + 1} done. ({processed_count}/{total_products})")
199
+
200
+ # # Final Success
201
+ # await update_job_safe(db_factory, job_id, JobStatus.COMPLETED, processed=processed_count, total=total_products)
202
+ # print(f"🎉 Job {job_id} Complete. {processed_count} images indexed.")
203
+
204
+ # except Exception as e:
205
+ # print(f"❌ Job {job_id} Failed: {e}")
206
+ # await update_job_safe(db_factory, job_id, JobStatus.FAILED, error=str(e))
207
+ import asyncio
208
+ import json
209
+ import requests
210
+ import concurrent.futures
211
+ from uuid import uuid4
212
+ from sqlalchemy.future import select
213
+ from qdrant_client.http import models
214
+
215
+ # Internal Modules
216
+ from backend.src.models.integration import UserIntegration
217
+ from backend.src.models.ingestion import IngestionJob, JobStatus
218
+ from backend.src.services.vector_store.qdrant_adapter import get_vector_store
219
+ from backend.src.services.visual.engine import get_image_embedding
220
+
221
+ # Connectors
222
+ from backend.src.services.connectors.sanity_connector import SanityConnector
223
+ from backend.src.services.connectors.shopify_connector import fetch_all_products as fetch_shopify
224
+ from backend.src.services.connectors.woocommerce_connector import fetch_all_products as fetch_woo
225
+
226
+ # --- OPTIMIZATION CONFIG ---
227
+ BATCH_SIZE = 100
228
+ MAX_WORKERS = 20
229
+
230
+ # --- 🔥 SAFE LOGGING HELPER ---
231
+ async def update_job_safe(db_factory, job_id: int, status: str, processed=0, total=0, error=None, message=None):
232
+ try:
233
+ async with db_factory() as db:
234
+ result = await db.execute(select(IngestionJob).where(IngestionJob.id == job_id))
235
+ job = result.scalars().first()
236
+ if job:
237
+ job.status = status
238
+ job.items_processed = processed
239
+ job.total_items = total
240
+ if error:
241
+ job.error_message = str(error)
242
+ # Agar hum koi custom message save karna chahein
243
+ if message:
244
+ print(f"📝 Job Log: {message}")
245
+ await db.commit()
246
+ except Exception as e:
247
+ print(f"⚠️ Status Update Failed: {e}")
248
+
249
+ async def fetch_products_from_source(provider: str, credentials: dict):
250
+ products = []
251
+ print(f"🔄 [Visual Agent] Fetching products from {provider}...")
252
+ try:
253
+ if provider == 'sanity':
254
+ connector = SanityConnector(credentials)
255
+ query = """*[_type == "product" && defined(variants)]{
256
+ _id, "slug": slug.current, "variants": variants[]{ _key, images[]{ asset->{url} } }
257
+ }"""
258
+ raw_data = connector.execute_query(query)
259
+ for item in raw_data:
260
+ if not item.get('variants'): continue
261
+ for variant in item['variants']:
262
+ if not variant.get('images'): continue
263
+ for img in variant['images']:
264
+ if img.get('asset'):
265
+ products.append({
266
+ "id": f"{item['_id']}_{variant['_key']}",
267
+ "image_path": img['asset']['url'],
268
+ "slug": item.get('slug'),
269
+ "product_id": item['_id']
270
+ })
271
+ elif provider == 'shopify':
272
+ products = await asyncio.to_thread(fetch_shopify, credentials)
273
+ elif provider == 'woocommerce':
274
+ products = await asyncio.to_thread(fetch_woo, credentials)
275
+ return products
276
+ except Exception as e:
277
+ print(f"❌ Fetch Error: {e}")
278
+ return []
279
+
280
+ def download_and_vectorize(product):
281
+ # Ensure we use the correct key for image path
282
+ image_url = product.get('image_path') or product.get('image_url')
283
+
284
+ if not image_url:
285
+ return None
286
+
287
+ try:
288
+ response = requests.get(image_url, timeout=5)
289
+ if response.status_code != 200: return None
290
+ image_bytes = response.content
291
+ vector = get_image_embedding(image_bytes)
292
+ if not vector: return None
293
+ return {"product": product, "vector": vector}
294
+ except Exception:
295
+ return None
296
+
297
+ # --- 🧠 SMART DIFF HELPER ---
298
+ async def get_current_qdrant_state(client, collection_name, user_id):
299
+ """
300
+ Qdrant se sirf IDs aur Image URLs fetch karta hai taake hum compare kar sakein.
301
+ Returns: Dict { 'product_unique_id::image_url': 'qdrant_uuid' }
302
+ """
303
+ state = {}
304
+ next_offset = None
305
+
306
+ print(f"🕵️ Scanning existing Qdrant data for User: {user_id} in '{collection_name}'...")
307
+
308
+ while True:
309
+ # Scroll through points (Pagination)
310
+ records, next_offset = await asyncio.to_thread(
311
+ client.scroll,
312
+ collection_name=collection_name,
313
+ scroll_filter=models.Filter(
314
+ must=[models.FieldCondition(key="user_id", match=models.MatchValue(value=str(user_id)))]
315
+ ),
316
+ limit=1000,
317
+ with_payload=True,
318
+ with_vectors=False, # Vector download karne ki zarurat nahi, slow hota hai
319
+ offset=next_offset
320
+ )
321
+
322
+ for point in records:
323
+ payload = point.payload or {}
324
+
325
+ prod_id = payload.get("product_id")
326
+ img_url = payload.get("image_url")
327
+
328
+ if prod_id and img_url:
329
+ # Composite key create karte hain uniquely identify karne ke liye
330
+ key = f"{prod_id}::{img_url}"
331
+ state[key] = point.id # Save Qdrant UUID (Delete karne ke kaam ayega)
332
+
333
+ if next_offset is None:
334
+ break
335
+
336
+ print(f"✅ Found {len(state)} existing records in DB.")
337
+ return state
338
+
339
+ async def run_visual_sync(user_id: str, job_id: int, db_factory):
340
+ """
341
+ 🚀 Smart Incremental Sync:
342
+ 1. Fetch Source Data
343
+ 2. Fetch DB State
344
+ 3. Calculate Diff (Add/Delete)
345
+ 4. Execute Updates
346
+ """
347
+ print(f"🚀 [Visual Agent] Starting Smart Sync Job {job_id} for User: {user_id}")
348
+
349
+ try:
350
+ await update_job_safe(db_factory, job_id, JobStatus.PROCESSING)
351
+
352
+ # 1. Credentials Fetch
353
+ async with db_factory() as db:
354
+ stmt = select(UserIntegration).where(
355
+ UserIntegration.user_id == str(user_id),
356
+ UserIntegration.is_active == True
357
+ )
358
+ result = await db.execute(stmt)
359
+ integrations = result.scalars().all()
360
+
361
+ qdrant_config = None
362
+ store_config = None
363
+ store_provider = None
364
+
365
+ for i in integrations:
366
+ if i.provider == 'qdrant':
367
+ qdrant_config = json.loads(i.credentials)
368
+ elif i.provider in ['sanity', 'shopify', 'woocommerce']:
369
+ store_config = json.loads(i.credentials)
370
+ store_provider = i.provider
371
+
372
+ if not qdrant_config or not store_config:
373
+ await update_job_safe(db_factory, job_id, JobStatus.FAILED, error="Missing Database or Store connection.")
374
+ return
375
+
376
+ # 2. Connect Qdrant & Check Collection
377
+ vector_store = get_vector_store(credentials=qdrant_config)
378
+
379
+ # 🔥 CRITICAL FIX: Explicitly look for 'visual_collection_name'
380
+ # Agar user ne visual naam nahi diya, to default 'visual_search_products' use karo.
381
+ # Hum 'collection_name' (jo chat ke liye hai) use NAHI karenge taake mix na ho.
382
+ collection_name = qdrant_config.get("visual_collection_name", "visual_search_products")
383
+
384
+ client = vector_store.client
385
+
386
+ # Ensure Collection Exists
387
+ if not client.collection_exists(collection_name):
388
+ print(f"🛠️ Creating new collection: {collection_name}")
389
+ client.create_collection(
390
+ collection_name=collection_name,
391
+ vectors_config=models.VectorParams(size=2048, distance=models.Distance.COSINE)
392
+ )
393
+ client.create_payload_index(
394
+ collection_name=collection_name,
395
+ field_name="user_id",
396
+ field_schema=models.PayloadSchemaType.KEYWORD
397
+ )
398
+
399
+ # 3. Fetch Data from Source (Fresh List)
400
+ source_products = await fetch_products_from_source(store_provider, store_config)
401
+ if not source_products:
402
+ await update_job_safe(db_factory, job_id, JobStatus.COMPLETED, error="No products found in store.")
403
+ return
404
+
405
+ # 4. Fetch Data from Qdrant (Existing List)
406
+ # Map: "ProductID::ImageURL" -> QdrantUUID
407
+ db_state = await get_current_qdrant_state(client, collection_name, user_id)
408
+
409
+ # 5. 🧠 CALCULATE THE DIFF (The Magic)
410
+ points_to_delete = []
411
+ items_to_process = []
412
+
413
+ # A. Identify New Items & Unchanged Items
414
+ source_keys = set()
415
+
416
+ for prod in source_products:
417
+ prod_id = prod.get('product_id')
418
+ img_url = prod.get('image_path') or prod.get('image_url')
419
+
420
+ if not prod_id or not img_url: continue
421
+
422
+ key = f"{prod_id}::{img_url}"
423
+ source_keys.add(key)
424
+
425
+ if key in db_state:
426
+ # Already exists and Image URL is exact match -> SKIP (Save Time)
427
+ continue
428
+ else:
429
+ # New Item (or URL changed, which creates a new key) -> PROCESS
430
+ items_to_process.append(prod)
431
+
432
+ # B. Identify Deleted Items
433
+ # Agar koi cheez DB mein hai, lekin Source (source_keys) mein nahi, to wo delete hogi.
434
+ for db_key, db_uuid in db_state.items():
435
+ if db_key not in source_keys:
436
+ points_to_delete.append(db_uuid)
437
+
438
+ # Stats
439
+ total_source = len(source_products)
440
+ to_add_count = len(items_to_process)
441
+ to_delete_count = len(points_to_delete)
442
+ unchanged_count = total_source - to_add_count
443
+
444
+ print(f"📊 Sync Analysis for User {user_id}:")
445
+ print(f" - Collection: {collection_name}")
446
+ print(f" - Total in Store: {total_source}")
447
+ print(f" - Unchanged (Skipping): {unchanged_count}")
448
+ print(f" - To Add/Update: {to_add_count}")
449
+ print(f" - To Delete (Removed from Store): {to_delete_count}")
450
+
451
+ # 6. EXECUTE DELETE (Agar kuch delete karna ho)
452
+ if points_to_delete:
453
+ print(f"🗑️ Deleting {to_delete_count} obsolete records...")
454
+ # Qdrant delete by Point ID (UUID)
455
+ # Batching deletes if too many
456
+ chunk_size = 1000
457
+ for i in range(0, len(points_to_delete), chunk_size):
458
+ chunk = points_to_delete[i:i + chunk_size]
459
+ client.delete(
460
+ collection_name=collection_name,
461
+ points_selector=models.PointIdsList(points=chunk)
462
+ )
463
+
464
+ # 7. EXECUTE ADD/UPDATE (Batch Processing)
465
+ if items_to_process:
466
+ print(f"⚡ Processing {to_add_count} new images...")
467
+ processed_count = 0
468
+
469
+ # Initial status update
470
+ await update_job_safe(db_factory, job_id, JobStatus.PROCESSING, total=to_add_count, processed=0)
471
+
472
+ loop = asyncio.get_running_loop()
473
+
474
+ for i in range(0, len(items_to_process), BATCH_SIZE):
475
+ batch = items_to_process[i : i + BATCH_SIZE]
476
+ points = []
477
+
478
+ # Parallel Download & Vectorize
479
+ with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
480
+ futures = [
481
+ loop.run_in_executor(executor, download_and_vectorize, item)
482
+ for item in batch
483
+ ]
484
+ results = await asyncio.gather(*futures)
485
+
486
+ for res in results:
487
+ if res:
488
+ prod = res['product']
489
+ img_url = prod.get('image_path') or prod.get('image_url')
490
+
491
+ points.append(models.PointStruct(
492
+ id=str(uuid4()),
493
+ vector=res['vector'],
494
+ payload={
495
+ "product_id": prod.get('product_id'),
496
+ "slug": prod.get('slug'),
497
+ "image_url": img_url,
498
+ "user_id": str(user_id),
499
+ "source": store_provider
500
+ }
501
+ ))
502
+
503
+ if points:
504
+ await asyncio.to_thread(
505
+ client.upsert,
506
+ collection_name=collection_name,
507
+ points=points
508
+ )
509
+ processed_count += len(points)
510
+
511
+ # Progress Update
512
+ await update_job_safe(db_factory, job_id, JobStatus.PROCESSING, processed=processed_count, total=to_add_count)
513
+ print(f" -> Batch {i//BATCH_SIZE + 1} done. ({processed_count}/{to_add_count})")
514
+ else:
515
+ print("✨ No new images to process.")
516
+
517
+ # Final Success
518
+ final_msg = f"Sync Complete. Added: {to_add_count}, Deleted: {to_delete_count}, Skipped: {unchanged_count}"
519
+ await update_job_safe(db_factory, job_id, JobStatus.COMPLETED, processed=to_add_count, total=to_add_count, message=final_msg)
520
+ print(f"🎉 {final_msg}")
521
+
522
+ except Exception as e:
523
+ print(f"❌ Job {job_id} Failed: {e}")
524
+ await update_job_safe(db_factory, job_id, JobStatus.FAILED, error=str(e))
backend/src/services/visual/engine.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/src/services/visual/engine.py
2
+
3
+ import torch
4
+ import torchvision.models as models
5
+ import torchvision.transforms as transforms
6
+ from PIL import Image
7
+ import numpy as np
8
+ from io import BytesIO
9
+
10
+ # Global variable taake Model sirf ek baar load ho (Memory Bachane ke liye)
11
+ _visual_model_instance = None
12
+ _preprocess_instance = None
13
+
14
+ def get_visual_model():
15
+ """
16
+ Singleton Pattern: ResNet50 ko sirf tab load karega jab pehli baar zaroorat hogi.
17
+ Railway/Serverless par RAM bachane ke liye zaroori hai.
18
+ """
19
+ global _visual_model_instance, _preprocess_instance
20
+
21
+ if _visual_model_instance is None:
22
+ print("👁️ [Visual Engine] Loading ResNet50 AI Model...")
23
+ # 1. Load Standard ResNet50
24
+ full_model = models.resnet50(weights=models.ResNet50_Weights.DEFAULT)
25
+ full_model.eval() # Inference mode (Training off)
26
+
27
+ # 2. Remove the last Classification Layer
28
+ # Humein "Cat/Dog" label nahi chahiye, humein features (vectors) chahiye.
29
+ _visual_model_instance = torch.nn.Sequential(*(list(full_model.children())[:-1]))
30
+
31
+ # 3. Define Image Preprocessing steps
32
+ _preprocess_instance = transforms.Compose([
33
+ transforms.Resize(256),
34
+ transforms.CenterCrop(224),
35
+ transforms.ToTensor(),
36
+ transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
37
+ ])
38
+ print("✅ [Visual Engine] Model Loaded Successfully.")
39
+
40
+ return _visual_model_instance, _preprocess_instance
41
+
42
+ def get_image_embedding(image_bytes: bytes) -> list:
43
+ """
44
+ Image ke bytes leta hai -> ResNet50 se guzarta hai -> 2048 numbers ki list wapis karta hai.
45
+ """
46
+ model, preprocess = get_visual_model()
47
+
48
+ try:
49
+ # 1. Convert Bytes to Image
50
+ img = Image.open(BytesIO(image_bytes)).convert("RGB")
51
+
52
+ # 2. Preprocess
53
+ img_tensor = preprocess(img)
54
+ batch_img_tensor = torch.unsqueeze(img_tensor, 0) # Batch dimension add karein
55
+
56
+ # 3. Generate Embedding
57
+ with torch.no_grad():
58
+ embedding = model(batch_img_tensor)
59
+
60
+ # 4. Flatten & Normalize (Cosine Similarity ke liye zaroori)
61
+ embedding_np = embedding.flatten().numpy()
62
+ norm = np.linalg.norm(embedding_np)
63
+
64
+ # Zero division se bachne ke liye
65
+ if norm == 0:
66
+ return embedding_np.tolist()
67
+
68
+ normalized_embedding = (embedding_np / norm).astype('float32')
69
+
70
+ return normalized_embedding.tolist()
71
+
72
+ except Exception as e:
73
+ print(f"❌ [Visual Engine] Error processing image: {e}")
74
+ return None
backend/src/update_db.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from sqlalchemy import text
3
+ from backend.src.db.session import engine
4
+
5
+ async def upgrade_database():
6
+ print("⚙️ Updating Database Schema (Without Deleting Data)...")
7
+
8
+ async with engine.begin() as conn:
9
+ # --- 1. Users Table mein 'api_key' add karna ---
10
+ try:
11
+ print(" -> Adding 'api_key' column to users...")
12
+ await conn.execute(text("ALTER TABLE users ADD COLUMN api_key VARCHAR;"))
13
+ except Exception as e:
14
+ print(" (Skipped: Column shayad pehle se hai)")
15
+
16
+ # --- 2. Users Table mein 'allowed_domains' add karna ---
17
+ try:
18
+ print(" -> Adding 'allowed_domains' column to users...")
19
+ await conn.execute(text("ALTER TABLE users ADD COLUMN allowed_domains VARCHAR DEFAULT '*';"))
20
+ except Exception as e:
21
+ print(" (Skipped: Column shayad pehle se hai)")
22
+
23
+ # --- 3. Integrations Table mein 'profile_description' add karna ---
24
+ try:
25
+ print(" -> Adding 'profile_description' column to user_integrations...")
26
+ await conn.execute(text("ALTER TABLE user_integrations ADD COLUMN profile_description TEXT;"))
27
+ except Exception as e:
28
+ print(" (Skipped: Column shayad pehle se hai)")
29
+
30
+ print("✅ Database Update Complete! Aapka purana data safe hai.")
31
+
32
+ # --- 4. Purane Users ke liye API Key Generate karna ---
33
+ # Kyunki purane users ki API Key NULL hogi, unhein nayi key deni padegi.
34
+ from backend.src.utils.auth import generate_api_key
35
+ from sqlalchemy.future import select
36
+ from backend.src.models.user import User
37
+ from backend.src.db.session import AsyncSessionLocal
38
+
39
+ print("🔑 Generating API Keys for existing users...")
40
+ async with AsyncSessionLocal() as db:
41
+ result = await db.execute(select(User))
42
+ users = result.scalars().all()
43
+
44
+ count = 0
45
+ for user in users:
46
+ if not user.api_key: # Agar key nahi hai
47
+ user.api_key = generate_api_key()
48
+ user.allowed_domains = "*"
49
+ db.add(user)
50
+ count += 1
51
+ print(f" -> Key generated for: {user.email}")
52
+
53
+ await db.commit()
54
+ print(f"✅ {count} Users updated with new API Keys.")
55
+
56
+ if __name__ == "__main__":
57
+ asyncio.run(upgrade_database())
backend/src/utils/auth.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import secrets # Cryptographically strong random numbers generate karne ke liye
2
  from passlib.context import CryptContext
3
  from datetime import datetime, timedelta
 
1
+ # backend/src/utils/auth.py
2
  import secrets # Cryptographically strong random numbers generate karne ke liye
3
  from passlib.context import CryptContext
4
  from datetime import datetime, timedelta
backend/src/utils/security.py CHANGED
@@ -1,30 +1,61 @@
1
- # --- EXTERNAL IMPORTS ---
 
2
  from cryptography.fernet import Fernet
3
- import base64
4
 
5
- # --- FIX: A Valid, Consistent 32-byte Base64 Key ---
6
- # Ye key change nahi hogi, to decryption hamesha chalega.
7
- DEFAULT_KEY = b'8_sW7x9y2z4A5b6C8d9E0f1G2h3I4j5K6l7M8n9O0pQ='
 
 
 
 
8
 
9
  class SecurityUtils:
10
  @staticmethod
11
  def get_cipher():
12
- # Production mein ye .env se aana chahiye
13
- # Development ke liye hum hardcoded valid key use kar rahe hain
14
- return Fernet(DEFAULT_KEY)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  @staticmethod
17
  def encrypt(data: str) -> str:
 
18
  if not data: return ""
19
- cipher = SecurityUtils.get_cipher()
20
- return cipher.encrypt(data.encode()).decode()
 
 
 
 
 
21
 
22
  @staticmethod
23
  def decrypt(token: str) -> str:
 
24
  if not token: return ""
25
- cipher = SecurityUtils.get_cipher()
26
  try:
 
27
  return cipher.decrypt(token.encode()).decode()
28
  except Exception as e:
 
29
  print(f"🔐 Decryption Failed: {e}")
30
  raise ValueError("Invalid Key or Corrupted Data")
 
1
+ # backend/src/utils/security.py
2
+ import os
3
  from cryptography.fernet import Fernet
4
+ from dotenv import load_dotenv
5
 
6
+ load_dotenv()
7
+
8
+ # --- SECURITY CONFIGURATION ---
9
+ # 1. Production mein yeh key hamesha .env file mein honi chahiye: ENCRYPTION_KEY=...
10
+ # 2. Yeh niche wali key maine Fernet.generate_key() se generate ki hai.
11
+ # Yeh valid format mein hai, isay use karein taake crash na ho.
12
+ FALLBACK_KEY = b'gQp8v5Y3Z9k1L0mN2oP4rS6tU8vW0xY2z4A6bC8dE0f='
13
 
14
  class SecurityUtils:
15
  @staticmethod
16
  def get_cipher():
17
+ """
18
+ Encryption Cipher banata hai.
19
+ Priority:
20
+ 1. OS Environment Variable (Best for Production)
21
+ 2. Hardcoded Fallback (Only for Local Dev)
22
+ """
23
+ key = os.getenv("ENCRYPTION_KEY")
24
+
25
+ if not key:
26
+ # Agar .env mein key nahi hai, to fallback use karo aur warning do
27
+ # print("⚠️ WARNING: Using insecure fallback encryption key!")
28
+ return Fernet(FALLBACK_KEY)
29
+
30
+ try:
31
+ # Agar .env se key aayi hai, to usay bytes mein convert karo
32
+ if isinstance(key, str):
33
+ key = key.encode()
34
+ return Fernet(key)
35
+ except Exception:
36
+ print("❌ ERROR: Invalid ENCRYPTION_KEY in .env. Falling back to default.")
37
+ return Fernet(FALLBACK_KEY)
38
 
39
  @staticmethod
40
  def encrypt(data: str) -> str:
41
+ """String ko encrypt karke encrypted string return karta hai"""
42
  if not data: return ""
43
+ try:
44
+ cipher = SecurityUtils.get_cipher()
45
+ # Encrypt karne ke liye bytes chahiye, wapis string banate waqt decode
46
+ return cipher.encrypt(data.encode()).decode()
47
+ except Exception as e:
48
+ print(f"🔐 Encryption Failed: {e}")
49
+ raise e
50
 
51
  @staticmethod
52
  def decrypt(token: str) -> str:
53
+ """Encrypted string ko wapis original text mein lata hai"""
54
  if not token: return ""
 
55
  try:
56
+ cipher = SecurityUtils.get_cipher()
57
  return cipher.decrypt(token.encode()).decode()
58
  except Exception as e:
59
+ # Agar key change hui ya data corrupt hua to ye error dega
60
  print(f"🔐 Decryption Failed: {e}")
61
  raise ValueError("Invalid Key or Corrupted Data")
docker-compose.yml CHANGED
@@ -1,3 +1,4 @@
 
1
  version: '3.8'
2
 
3
  services:
 
1
+ # Docker Compose File for Local Development
2
  version: '3.8'
3
 
4
  services:
frontend/visual_search_test.html ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>OmniAgent Visual Search Tester (API Key Mode)</title>
7
+ <style>
8
+ body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f4f7f9; color: #333; margin: 0; padding: 40px; display: flex; justify-content: center; }
9
+ .container { width: 100%; max-width: 900px; background: #fff; padding: 30px; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); }
10
+ h1 { color: #2c3e50; text-align: center; margin-bottom: 10px; }
11
+ .subtitle { text-align: center; color: #7f8c8d; margin-bottom: 30px; font-size: 0.9em; }
12
+
13
+ .input-group { display: flex; flex-direction: column; gap: 15px; margin-bottom: 20px; }
14
+ .input-group label { font-weight: bold; margin-bottom: -10px; font-size: 0.9em; color: #555; }
15
+ .input-group input, .input-group button { padding: 12px; border-radius: 6px; border: 1px solid #ccc; font-size: 16px; }
16
+
17
+ .input-group button { background-color: #3498db; color: white; border: none; cursor: pointer; transition: background-color 0.3s; font-weight: bold; }
18
+ .input-group button:hover { background-color: #2980b9; }
19
+ .input-group button:disabled { background-color: #bdc3c7; cursor: not-allowed; }
20
+
21
+ #auth-status { text-align: center; padding: 15px; background: #f8f9fa; border-radius: 6px; margin-bottom: 20px; border-left: 5px solid #bdc3c7; }
22
+
23
+ #search-results-container { margin-top: 30px; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; }
24
+
25
+ .result-card { background-color: #fff; border: 1px solid #eee; border-radius: 8px; overflow: hidden; transition: transform 0.2s, box-shadow 0.2s; }
26
+ .result-card:hover { transform: translateY(-5px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }
27
+ .result-card img { width: 100%; height: 200px; object-fit: cover; display: block; border-bottom: 1px solid #eee; }
28
+ .card-content { padding: 12px; text-align: center; }
29
+ .similarity-badge { background: #27ae60; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: bold; }
30
+
31
+ .info-message { grid-column: 1 / -1; text-align: center; font-size: 16px; color: #777; margin-top: 20px; }
32
+ .loader { border: 4px solid #f3f3f3; border-top: 4px solid #3498db; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; display: none; }
33
+ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <div class="container">
38
+ <h1>OmniAgent Visual Search</h1>
39
+ <p class="subtitle">Production Test Interface (API Key Secured)</p>
40
+
41
+ <div id="auth-status">
42
+ <p style="margin:0"><strong>Status:</strong> Waiting for API Key...</p>
43
+ </div>
44
+
45
+ <div class="input-group">
46
+ <label for="api-key-input">1. Enter API Key (Get from Dashboard)</label>
47
+ <input type="text" id="api-key-input" placeholder="omni_xxxxxxxxxxxxxxxxxxxxxxxx...">
48
+
49
+ <label for="image-upload-input">2. Upload Query Image</label>
50
+ <input type="file" id="image-upload-input" accept="image/*">
51
+
52
+ <button id="search-button" disabled>🔍 Search Products</button>
53
+ </div>
54
+
55
+ <div id="loader" class="loader"></div>
56
+ <div id="search-results-container">
57
+ <p class="info-message">Results will appear here...</p>
58
+ </div>
59
+ </div>
60
+
61
+ <script src="visual_search_test.js"></script>
62
+ <script>
63
+ // Backend URL (Ensure Port is Correct)
64
+ const API_ENDPOINT = "http://127.0.0.1:8000/api/v1/visual/search";
65
+ VisualSearchTester.init(API_ENDPOINT);
66
+ </script>
67
+ </body>
68
+ </html>
frontend/visual_search_test.js ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/visual_search_test.js
2
+
3
+ const VisualSearchTester = {
4
+ config: {
5
+ apiEndpoint: "",
6
+ },
7
+ elements: {},
8
+
9
+ init: function(apiEndpoint) {
10
+ this.config.apiEndpoint = apiEndpoint;
11
+ this.elements = {
12
+ apiKeyInput: document.getElementById('api-key-input'), // ID Changed
13
+ fileInput: document.getElementById('image-upload-input'),
14
+ searchButton: document.getElementById('search-button'),
15
+ authStatus: document.getElementById('auth-status'),
16
+ loader: document.getElementById('loader'),
17
+ resultsContainer: document.getElementById('search-results-container'),
18
+ };
19
+
20
+ // Listeners
21
+ this.elements.apiKeyInput.addEventListener('input', this._updateButtonState.bind(this));
22
+ this.elements.fileInput.addEventListener('change', this._updateButtonState.bind(this));
23
+ this.elements.searchButton.addEventListener('click', this._handleSearch.bind(this));
24
+
25
+ this._updateButtonState();
26
+ },
27
+
28
+ _updateButtonState: function() {
29
+ const apiKey = this.elements.apiKeyInput.value.trim();
30
+ const file = this.elements.fileInput.files[0];
31
+
32
+ // API Key 'omni_' se shuru hoti hai aur approx 30-40 chars hoti hai
33
+ const isValidKey = apiKey.length > 20 && apiKey.startsWith("omni_");
34
+ const canSearch = isValidKey && file;
35
+
36
+ this.elements.searchButton.disabled = !canSearch;
37
+
38
+ if (isValidKey) {
39
+ this.elements.authStatus.style.borderLeftColor = '#27ae60';
40
+ this.elements.authStatus.innerHTML = '<p style="color: #27ae60; margin:0"><strong>Status:</strong> Valid API Key Format ✅</p>';
41
+ } else {
42
+ this.elements.authStatus.style.borderLeftColor = '#bdc3c7';
43
+ this.elements.authStatus.innerHTML = '<p style="color: #7f8c8d; margin:0"><strong>Status:</strong> Waiting for valid API Key...</p>';
44
+ }
45
+ },
46
+
47
+ // --- 🔥 MAIN SEARCH LOGIC (API KEY UPDATE) 🔥 ---
48
+ _handleSearch: async function() {
49
+ const apiKey = this.elements.apiKeyInput.value.trim();
50
+ const file = this.elements.fileInput.files[0];
51
+
52
+ if (!apiKey || !file) return;
53
+
54
+ this.elements.loader.style.display = 'block';
55
+ this.elements.searchButton.disabled = true; // Prevent double click
56
+ this.elements.resultsContainer.innerHTML = '';
57
+
58
+ const formData = new FormData();
59
+ formData.append('file', file);
60
+
61
+ try {
62
+ console.log("Sending request to:", this.config.apiEndpoint);
63
+
64
+ const response = await fetch(this.config.apiEndpoint, {
65
+ method: 'POST',
66
+ headers: {
67
+ // ✅ CHANGE: Use 'x-api-key' instead of 'Authorization'
68
+ 'x-api-key': apiKey,
69
+ 'Accept': 'application/json'
70
+ // Note: 'Content-Type' header mat lagana jab FormData bhej rahe ho, browser khud boundary set karega.
71
+ },
72
+ body: formData
73
+ });
74
+
75
+ const data = await response.json();
76
+
77
+ if (!response.ok) {
78
+ // Handle specific Auth errors
79
+ if (response.status === 401 || response.status === 403) {
80
+ throw new Error("Authentication Failed! Please check your API Key or Domain Settings.");
81
+ }
82
+ const errorDetail = data.detail || data.message || "Unknown server error";
83
+ throw new Error(`Error ${response.status}: ${errorDetail}`);
84
+ }
85
+
86
+ this._renderResults(data.results);
87
+
88
+ } catch (error) {
89
+ this.elements.resultsContainer.innerHTML = `
90
+ <div style="text-align:center; padding: 20px;">
91
+ <p style="color: #e74c3c; font-weight: bold; font-size: 1.2em;">❌ Search Failed</p>
92
+ <p style="color: #555;">${error.message}</p>
93
+ </div>`;
94
+ console.error("Visual Search Error:", error);
95
+ } finally {
96
+ this.elements.loader.style.display = 'none';
97
+ this.elements.searchButton.disabled = false;
98
+ }
99
+ },
100
+
101
+ _renderResults: function(results) {
102
+ const container = this.elements.resultsContainer;
103
+ container.innerHTML = '';
104
+
105
+ if (!results || results.length === 0) {
106
+ container.innerHTML = '<p class="info-message">🤷‍♂️ No matching products found.</p>';
107
+ return;
108
+ }
109
+
110
+ results.forEach(item => {
111
+ const card = document.createElement('div');
112
+ card.className = 'result-card';
113
+
114
+ const similarityScore = (item.similarity * 100).toFixed(1);
115
+ const slug = item.slug || "#";
116
+
117
+ // Image Fallback logic
118
+ const imgUrl = item.image_path || "https://via.placeholder.com/200?text=No+Image";
119
+
120
+ card.innerHTML = `
121
+ <a href="/product/${slug}" target="_blank" style="text-decoration: none; color: inherit;">
122
+ <img src="${imgUrl}" alt="Product Image" onerror="this.src='https://via.placeholder.com/200?text=Error'">
123
+ <div class="card-content">
124
+ <span class="similarity-badge">${similarityScore}% Match</span>
125
+ <p style="margin-top: 10px; font-size: 0.9em; color: #555;">ID: ${item.product_id}</p>
126
+ </div>
127
+ </a>
128
+ `;
129
+ container.appendChild(card);
130
+ });
131
+ }
132
+ };
requirements.txt CHANGED
@@ -1,4 +1,3 @@
1
- # Requirements for the project
2
  accelerate==1.12.0
3
  aiofiles==25.1.0
4
  aiohappyeyeballs==2.6.1
@@ -122,6 +121,7 @@ proto-plus==1.26.1
122
  protobuf==6.33.2
123
  psutil==7.1.3
124
  psycopg2-binary==2.9.11
 
125
  pyasn1==0.6.1
126
  pyasn1_modules==0.4.2
127
  pycocotools==2.0.10
@@ -129,11 +129,13 @@ pycparser==2.23
129
  pydantic==2.12.5
130
  pydantic-settings==2.12.0
131
  pydantic_core==2.41.5
 
132
  pymongo==4.15.5
133
  pypandoc==1.16.2
134
  pyparsing==3.2.5
135
  pypdf==6.4.1
136
  pypdfium2==5.1.0
 
137
  python-dateutil==2.9.0.post0
138
  python-docx==1.2.0
139
  python-dotenv==1.2.1
@@ -144,6 +146,7 @@ python-multipart==0.0.20
144
  python-oxmsg==0.0.2
145
  python-pptx==1.0.2
146
  pytz==2025.2
 
147
  PyYAML==6.0.3
148
  qdrant-client==1.16.1
149
  RapidFuzz==3.14.3
@@ -156,6 +159,7 @@ scikit-learn==1.7.2
156
  scipy==1.16.3
157
  sentence-transformers==5.1.2
158
  setuptools==80.9.0
 
159
  six==1.17.0
160
  sniffio==1.3.1
161
  soupsieve==2.8
@@ -167,8 +171,8 @@ threadpoolctl==3.6.0
167
  tiktoken==0.12.0
168
  timm==1.0.22
169
  tokenizers==0.22.1
170
- torch==2.9.1+cpu
171
- torchvision==0.24.1+cpu
172
  tqdm==4.67.1
173
  transformers==4.57.3
174
  typing-inspect==0.9.0
@@ -185,6 +189,7 @@ uvicorn==0.38.0
185
  watchfiles==1.1.1
186
  webencodings==0.5.1
187
  websockets==15.0.1
 
188
  wrapt==2.0.1
189
  xlrd==2.0.2
190
  xlsxwriter==3.2.9
 
 
1
  accelerate==1.12.0
2
  aiofiles==25.1.0
3
  aiohappyeyeballs==2.6.1
 
121
  protobuf==6.33.2
122
  psutil==7.1.3
123
  psycopg2-binary==2.9.11
124
+ pyactiveresource==2.2.2
125
  pyasn1==0.6.1
126
  pyasn1_modules==0.4.2
127
  pycocotools==2.0.10
 
129
  pydantic==2.12.5
130
  pydantic-settings==2.12.0
131
  pydantic_core==2.41.5
132
+ PyJWT==2.10.1
133
  pymongo==4.15.5
134
  pypandoc==1.16.2
135
  pyparsing==3.2.5
136
  pypdf==6.4.1
137
  pypdfium2==5.1.0
138
+ pyreadline3==3.5.4
139
  python-dateutil==2.9.0.post0
140
  python-docx==1.2.0
141
  python-dotenv==1.2.1
 
146
  python-oxmsg==0.0.2
147
  python-pptx==1.0.2
148
  pytz==2025.2
149
+ pywin32==311
150
  PyYAML==6.0.3
151
  qdrant-client==1.16.1
152
  RapidFuzz==3.14.3
 
159
  scipy==1.16.3
160
  sentence-transformers==5.1.2
161
  setuptools==80.9.0
162
+ ShopifyAPI==12.7.0
163
  six==1.17.0
164
  sniffio==1.3.1
165
  soupsieve==2.8
 
171
  tiktoken==0.12.0
172
  timm==1.0.22
173
  tokenizers==0.22.1
174
+ torch==2.9.1
175
+ torchvision==0.24.1
176
  tqdm==4.67.1
177
  transformers==4.57.3
178
  typing-inspect==0.9.0
 
189
  watchfiles==1.1.1
190
  webencodings==0.5.1
191
  websockets==15.0.1
192
+ WooCommerce==3.0.0
193
  wrapt==2.0.1
194
  xlrd==2.0.2
195
  xlsxwriter==3.2.9
static/widget.js CHANGED
@@ -4,10 +4,10 @@
4
  // ----------------------------------------------------
5
  const scriptTag = document.currentScript;
6
 
7
- // Ab hum User ID nahi, balki secure API Key mangenge 🔑
8
  const API_KEY = scriptTag.getAttribute("data-api-key");
9
  const API_URL = scriptTag.getAttribute("data-api-url");
10
- const THEME_COLOR = scriptTag.getAttribute("data-theme-color") || "#FF0000"; // Default Red for your theme
11
 
12
  if (!API_KEY || !API_URL) {
13
  console.error("OmniAgent Security Error: data-api-key or data-api-url is missing!");
@@ -17,7 +17,7 @@
17
  const CHAT_SESSION_ID = "omni_session_" + Math.random().toString(36).slice(2, 11);
18
 
19
  // ----------------------------------------------------
20
- // 2. STYLES: UI & Responsive Design
21
  // ----------------------------------------------------
22
  const style = document.createElement('style');
23
  style.innerHTML = `
@@ -27,15 +27,15 @@
27
  }
28
  #omni-chat-btn {
29
  background: ${THEME_COLOR}; color: white; border: none; padding: 15px; border-radius: 50%;
30
- cursor: pointer; box-shadow: 0 4px 15px rgba(0,0,0,0.3); width: 60px; height: 60px; font-size: 24px;
31
- display: flex; align-items: center; justify-content: center; transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
32
  }
33
- #omni-chat-btn:hover { transform: scale(1.1) rotate(5deg); }
34
 
35
- #omni-chat-window {
36
- display: none; width: 370px; height: 550px; background: white; border-radius: 16px;
37
- box-shadow: 0 12px 40px rgba(0,0,0,0.2); flex-direction: column; overflow: hidden;
38
- margin-bottom: 20px; border: 1px solid #f0f0f0; animation: omniSlideUp 0.4s ease;
39
  }
40
 
41
  @keyframes omniSlideUp {
@@ -43,135 +43,241 @@
43
  to { opacity: 1; transform: translateY(0); }
44
  }
45
 
46
- #omni-header {
47
- background: ${THEME_COLOR}; color: white; padding: 18px; font-weight: 600; display: flex;
48
- justify-content: space-between; align-items: center; letter-spacing: 0.5px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  }
50
- #omni-messages { flex: 1; padding: 20px; overflow-y: auto; background: #ffffff; display: flex; flex-direction: column; }
51
- #omni-input-area { display: flex; border-top: 1px solid #eee; background: #fff; padding: 10px; }
52
- #omni-input { flex: 1; padding: 12px; border: 1px solid #eee; border-radius: 25px; outline: none; font-size: 14px; background: #f8f9fa; }
53
- #omni-send { background: transparent; border: none; color: ${THEME_COLOR}; font-weight: bold; cursor: pointer; padding: 0 12px; font-size: 22px; }
54
 
55
- .omni-msg { margin: 10px 0; padding: 12px 16px; border-radius: 18px; max-width: 85%; font-size: 14px; line-height: 1.5; word-wrap: break-word; position: relative; }
56
- .omni-msg.user { background: ${THEME_COLOR}; color: white; align-self: flex-end; border-bottom-right-radius: 2px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); }
57
- .omni-msg.bot { background: #f0f2f5; color: #1c1e21; align-self: flex-start; border-bottom-left-radius: 2px; }
 
 
 
 
58
 
59
- /* Custom Scrollbar */
60
- #omni-messages::-webkit-scrollbar { width: 5px; }
61
- #omni-messages::-webkit-scrollbar-track { background: #f1f1f1; }
62
- #omni-messages::-webkit-scrollbar-thumb { background: #ccc; border-radius: 10px; }
63
  `;
64
  document.head.appendChild(style);
65
 
66
  // ----------------------------------------------------
67
- // 3. UI LOGIC: Global Toggle Function
68
- // ----------------------------------------------------
69
- window.toggleOmniChat = function() {
70
- const win = document.getElementById('omni-chat-window');
71
- if (!win) return;
72
- const isVisible = win.style.display === 'flex';
73
- win.style.display = isVisible ? 'none' : 'flex';
74
- if (!isVisible) {
75
- document.getElementById('omni-input').focus();
76
- }
77
- };
78
-
79
- // ----------------------------------------------------
80
- // 4. HTML STRUCTURE: Dynamic Insertion
81
  // ----------------------------------------------------
82
  const container = document.createElement('div');
83
  container.id = 'omni-widget-container';
84
 
85
  container.innerHTML = `
86
- <div id="omni-chat-window">
87
  <div id="omni-header">
88
- <div style="display:flex; align-items:center; gap:10px;">
89
- <div style="width:10px; height:10px; background:#2ecc71; border-radius:50%;"></div>
90
- <span>AI Knowledge Assistant</span>
91
  </div>
92
- <span style="cursor:pointer; font-size: 24px; font-weight:300;" onclick="window.toggleOmniChat()">×</span>
93
  </div>
94
- <div id="omni-messages"></div>
95
- <div id="omni-input-area">
96
- <input type="text" id="omni-input" placeholder="Type a message..." autocomplete="off" />
97
- <button id="omni-send">➤</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  </div>
99
  </div>
100
- <button id="omni-chat-btn" onclick="window.toggleOmniChat()">💬</button>
101
  `;
102
 
103
  document.body.appendChild(container);
104
 
105
  // ----------------------------------------------------
106
- // 5. CHAT ENGINE: Fetch & Security Headers
107
  // ----------------------------------------------------
108
- const inputField = document.getElementById('omni-input');
109
- const sendButton = document.getElementById('omni-send');
110
- const messagesContainer = document.getElementById('omni-messages');
 
 
111
 
112
- function addMessage(text, sender) {
113
- const div = document.createElement('div');
114
- div.className = `omni-msg ${sender}`;
115
- // URL auto-linking logic
116
- div.innerHTML = text.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" style="color:inherit; text-decoration:underline;">$1</a>');
117
- messagesContainer.appendChild(div);
118
- messagesContainer.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
119
- }
 
 
 
 
 
 
 
 
 
 
 
120
 
121
  async function sendMessage() {
122
- const text = inputField.value.trim();
123
  if (!text) return;
124
 
125
- addMessage(text, 'user');
126
- inputField.value = '';
127
 
128
- // Loading dots logic
129
  const loadingDiv = document.createElement('div');
130
  loadingDiv.className = 'omni-msg bot';
131
- loadingDiv.innerHTML = '<span class="omni-dots">...</span>';
132
- messagesContainer.appendChild(loadingDiv);
133
- messagesContainer.scrollTop = messagesContainer.scrollHeight;
134
 
135
  try {
136
  const response = await fetch(`${API_URL}/api/v1/chat`, {
137
  method: 'POST',
138
- headers: {
139
- 'Content-Type': 'application/json'
140
- },
141
  body: JSON.stringify({
142
  message: text,
143
  session_id: CHAT_SESSION_ID,
144
- api_key: API_KEY // 🔑 Secure Auth
145
  })
146
  });
147
-
 
148
  const data = await response.json();
149
- messagesContainer.removeChild(loadingDiv);
150
-
151
- if (response.status === 401) {
152
- addMessage("🚫 Security Error: Invalid API Key.", 'bot');
153
- } else if (response.status === 403) {
154
- addMessage("🚫 Security Error: Domain not authorized.", 'bot');
155
  } else {
156
- addMessage(data.response || "I couldn't process that. Please try again.", 'bot');
157
  }
158
 
159
- } catch (error) {
160
- if (loadingDiv.parentNode) messagesContainer.removeChild(loadingDiv);
161
- addMessage("📡 Connection lost. Is the AI server online?", 'bot');
162
- console.error("OmniAgent API Error:", error);
163
  }
164
  }
165
 
166
- // Event Listeners
167
- sendButton.addEventListener('click', sendMessage);
168
- inputField.addEventListener('keypress', (e) => {
169
- if(e.key === 'Enter') { sendMessage(); }
170
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
- // Initial Welcome (AI Persona)
173
- setTimeout(() => {
174
- addMessage("Hello! I am your AI assistant. How can I help you today?", "bot");
175
- }, 1500);
176
 
177
  })();
 
4
  // ----------------------------------------------------
5
  const scriptTag = document.currentScript;
6
 
7
+ // Auth & Config
8
  const API_KEY = scriptTag.getAttribute("data-api-key");
9
  const API_URL = scriptTag.getAttribute("data-api-url");
10
+ const THEME_COLOR = scriptTag.getAttribute("data-theme-color") || "#0084FF";
11
 
12
  if (!API_KEY || !API_URL) {
13
  console.error("OmniAgent Security Error: data-api-key or data-api-url is missing!");
 
17
  const CHAT_SESSION_ID = "omni_session_" + Math.random().toString(36).slice(2, 11);
18
 
19
  // ----------------------------------------------------
20
+ // 2. STYLES: UI & Responsive Design (Tabs + Camera)
21
  // ----------------------------------------------------
22
  const style = document.createElement('style');
23
  style.innerHTML = `
 
27
  }
28
  #omni-chat-btn {
29
  background: ${THEME_COLOR}; color: white; border: none; padding: 15px; border-radius: 50%;
30
+ cursor: pointer; box-shadow: 0 4px 15px rgba(0,0,0,0.3); width: 60px; height: 60px; font-size: 28px;
31
+ display: flex; align-items: center; justify-content: center; transition: all 0.3s;
32
  }
33
+ #omni-chat-btn:hover { transform: scale(1.1); }
34
 
35
+ #omni-window {
36
+ display: none; width: 380px; height: 600px; background: white; border-radius: 16px;
37
+ box-shadow: 0 12px 40px rgba(0,0,0,0.25); flex-direction: column; overflow: hidden;
38
+ margin-bottom: 20px; animation: omniSlideUp 0.3s ease; border: 1px solid #e0e0e0;
39
  }
40
 
41
  @keyframes omniSlideUp {
 
43
  to { opacity: 1; transform: translateY(0); }
44
  }
45
 
46
+ /* --- Header & Tabs --- */
47
+ #omni-header { background: ${THEME_COLOR}; padding: 15px; color: white; }
48
+ .omni-title { font-weight: bold; font-size: 16px; display: flex; align-items: center; gap: 8px; }
49
+ .omni-close { cursor: pointer; float: right; font-size: 24px; line-height: 16px; }
50
+
51
+ .omni-tabs { display: flex; background: #f1f1f1; border-bottom: 1px solid #ddd; }
52
+ .omni-tab { flex: 1; padding: 12px; text-align: center; cursor: pointer; font-size: 14px; font-weight: 600; color: #555; transition: 0.2s; }
53
+ .omni-tab.active { background: white; color: ${THEME_COLOR}; border-bottom: 3px solid ${THEME_COLOR}; }
54
+
55
+ /* --- Content Areas --- */
56
+ .omni-view { display: none; flex: 1; flex-direction: column; overflow: hidden; }
57
+ .omni-view.active { display: flex; }
58
+
59
+ /* --- Chat View --- */
60
+ #omni-messages { flex: 1; padding: 15px; overflow-y: auto; background: #f9f9f9; display: flex; flex-direction: column; gap: 10px; }
61
+ .omni-msg { padding: 10px 14px; border-radius: 12px; max-width: 80%; font-size: 14px; line-height: 1.4; }
62
+ .omni-msg.user { background: ${THEME_COLOR}; color: white; align-self: flex-end; border-bottom-right-radius: 2px; }
63
+ .omni-msg.bot { background: #e9eff5; color: #333; align-self: flex-start; border-bottom-left-radius: 2px; }
64
+
65
+ #omni-input-area { display: flex; border-top: 1px solid #eee; padding: 10px; background: white; }
66
+ #omni-input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 20px; outline: none; }
67
+ #omni-send { background: none; border: none; color: ${THEME_COLOR}; font-size: 20px; cursor: pointer; padding-left: 10px; }
68
+
69
+ /* --- Visual Search View --- */
70
+ #omni-visual-area { flex: 1; padding: 20px; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; overflow-y: auto; text-align: center; }
71
+ #omni-upload-box {
72
+ border: 2px dashed #ccc; border-radius: 12px; padding: 30px; margin-bottom: 20px;
73
+ width: 80%; cursor: pointer; transition: 0.2s; background: #fafafa;
74
  }
75
+ #omni-upload-box:hover { border-color: ${THEME_COLOR}; background: #f0f7ff; }
76
+ #omni-file-input { display: none; }
 
 
77
 
78
+ .omni-result-card {
79
+ display: flex; align-items: center; gap: 10px; background: white; border: 1px solid #eee;
80
+ padding: 10px; border-radius: 8px; width: 100%; margin-bottom: 10px; text-align: left;
81
+ transition: 0.2s; text-decoration: none; color: inherit;
82
+ }
83
+ .omni-result-card:hover { transform: translateY(-2px); box-shadow: 0 4px 10px rgba(0,0,0,0.1); }
84
+ .omni-result-img { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; }
85
 
86
+ .omni-loader { border: 3px solid #f3f3f3; border-top: 3px solid ${THEME_COLOR}; border-radius: 50%; width: 24px; height: 24px; animation: spin 1s linear infinite; margin: 20px auto; display: none; }
87
+ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
 
 
88
  `;
89
  document.head.appendChild(style);
90
 
91
  // ----------------------------------------------------
92
+ // 3. HTML STRUCTURE
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  // ----------------------------------------------------
94
  const container = document.createElement('div');
95
  container.id = 'omni-widget-container';
96
 
97
  container.innerHTML = `
98
+ <div id="omni-window">
99
  <div id="omni-header">
100
+ <span class="omni-close" onclick="window.toggleOmni()">×</span>
101
+ <div class="omni-title">
102
+ <span>🤖 AI Assistant</span>
103
  </div>
 
104
  </div>
105
+
106
+ <div class="omni-tabs">
107
+ <div class="omni-tab active" onclick="window.switchTab('chat')">💬 Chat</div>
108
+ <div class="omni-tab" onclick="window.switchTab('visual')">📷 Visual Search</div>
109
+ </div>
110
+
111
+ <!-- CHAT VIEW -->
112
+ <div id="omni-chat-view" class="omni-view active">
113
+ <div id="omni-messages"></div>
114
+ <div id="omni-input-area">
115
+ <input type="text" id="omni-input" placeholder="Ask anything..." autocomplete="off" />
116
+ <button id="omni-send">➤</button>
117
+ </div>
118
+ </div>
119
+
120
+ <!-- VISUAL VIEW -->
121
+ <div id="omni-visual-view" class="omni-view">
122
+ <div id="omni-visual-area">
123
+ <div id="omni-upload-box" onclick="document.getElementById('omni-file-input').click()">
124
+ <div style="font-size:40px; margin-bottom:10px;">📤</div>
125
+ <p style="margin:0; color:#666;">Click to Upload Image</p>
126
+ <p style="margin:0; font-size:12px; color:#999;">Find similar products</p>
127
+ </div>
128
+ <input type="file" id="omni-file-input" accept="image/*">
129
+ <div id="omni-visual-loader" class="omni-loader"></div>
130
+ <div id="omni-visual-results" style="width:100%;"></div>
131
+ </div>
132
  </div>
133
  </div>
134
+ <button id="omni-chat-btn" onclick="window.toggleOmni()">💬</button>
135
  `;
136
 
137
  document.body.appendChild(container);
138
 
139
  // ----------------------------------------------------
140
+ // 4. UI LOGIC (Toggle & Tabs)
141
  // ----------------------------------------------------
142
+ window.toggleOmni = function() {
143
+ const win = document.getElementById('omni-window');
144
+ const isVisible = win.style.display === 'flex';
145
+ win.style.display = isVisible ? 'none' : 'flex';
146
+ };
147
 
148
+ window.switchTab = function(tab) {
149
+ document.querySelectorAll('.omni-tab').forEach(t => t.classList.remove('active'));
150
+ document.querySelectorAll('.omni-view').forEach(v => v.classList.remove('active'));
151
+
152
+ if (tab === 'chat') {
153
+ document.querySelector('.omni-tabs .omni-tab:nth-child(1)').classList.add('active');
154
+ document.getElementById('omni-chat-view').classList.add('active');
155
+ } else {
156
+ document.querySelector('.omni-tabs .omni-tab:nth-child(2)').classList.add('active');
157
+ document.getElementById('omni-visual-view').classList.add('active');
158
+ }
159
+ };
160
+
161
+ // ----------------------------------------------------
162
+ // 5. CHAT ENGINE
163
+ // ----------------------------------------------------
164
+ const chatInput = document.getElementById('omni-input');
165
+ const sendBtn = document.getElementById('omni-send');
166
+ const msgContainer = document.getElementById('omni-messages');
167
 
168
  async function sendMessage() {
169
+ const text = chatInput.value.trim();
170
  if (!text) return;
171
 
172
+ addMsg(text, 'user');
173
+ chatInput.value = '';
174
 
 
175
  const loadingDiv = document.createElement('div');
176
  loadingDiv.className = 'omni-msg bot';
177
+ loadingDiv.innerHTML = '...';
178
+ msgContainer.appendChild(loadingDiv);
179
+ msgContainer.scrollTop = msgContainer.scrollHeight;
180
 
181
  try {
182
  const response = await fetch(`${API_URL}/api/v1/chat`, {
183
  method: 'POST',
184
+ headers: { 'Content-Type': 'application/json' },
 
 
185
  body: JSON.stringify({
186
  message: text,
187
  session_id: CHAT_SESSION_ID,
188
+ api_key: API_KEY // Auth
189
  })
190
  });
191
+
192
+ msgContainer.removeChild(loadingDiv);
193
  const data = await response.json();
194
+
195
+ if (response.ok) {
196
+ addMsg(data.response, 'bot');
 
 
 
197
  } else {
198
+ addMsg("⚠️ Error: " + (data.detail || "Server error"), 'bot');
199
  }
200
 
201
+ } catch (e) {
202
+ if(loadingDiv.parentNode) msgContainer.removeChild(loadingDiv);
203
+ addMsg("📡 Connection Error", 'bot');
 
204
  }
205
  }
206
 
207
+ function addMsg(text, sender) {
208
+ const div = document.createElement('div');
209
+ div.className = `omni-msg ${sender}`;
210
+ div.innerText = text; // Secure text insertion
211
+ msgContainer.appendChild(div);
212
+ msgContainer.scrollTop = msgContainer.scrollHeight;
213
+ }
214
+
215
+ sendBtn.onclick = sendMessage;
216
+ chatInput.onkeypress = (e) => { if(e.key === 'Enter') sendMessage(); };
217
+
218
+ // ----------------------------------------------------
219
+ // 6. VISUAL SEARCH ENGINE
220
+ // ----------------------------------------------------
221
+ const fileInput = document.getElementById('omni-file-input');
222
+ const visualLoader = document.getElementById('omni-visual-loader');
223
+ const visualResults = document.getElementById('omni-visual-results');
224
+
225
+ fileInput.onchange = async function() {
226
+ const file = fileInput.files[0];
227
+ if (!file) return;
228
+
229
+ visualLoader.style.display = 'block';
230
+ visualResults.innerHTML = '';
231
+
232
+ const formData = new FormData();
233
+ formData.append('file', file);
234
+
235
+ try {
236
+ const response = await fetch(`${API_URL}/api/v1/visual/search`, {
237
+ method: 'POST',
238
+ headers: {
239
+ 'x-api-key': API_KEY // Header Auth ✅
240
+ },
241
+ body: formData
242
+ });
243
+
244
+ const data = await response.json();
245
+ visualLoader.style.display = 'none';
246
+
247
+ if (!response.ok) {
248
+ visualResults.innerHTML = `<p style="color:red; margin-top:20px;">Error: ${data.detail}</p>`;
249
+ return;
250
+ }
251
+
252
+ if (!data.results || data.results.length === 0) {
253
+ visualResults.innerHTML = '<p style="color:#777; margin-top:20px;">No similar products found.</p>';
254
+ return;
255
+ }
256
+
257
+ // Render Results
258
+ data.results.forEach(item => {
259
+ const score = (item.similarity * 100).toFixed(0);
260
+ const el = document.createElement('a');
261
+ el.className = 'omni-result-card';
262
+ el.href = `/product/${item.slug || '#'}`; // Dynamic link
263
+ el.target = '_blank';
264
+ el.innerHTML = `
265
+ <img src="${item.image_path}" class="omni-result-img" onerror="this.src='https://via.placeholder.com/60'">
266
+ <div>
267
+ <div style="font-weight:bold; font-size:14px;">Product Match</div>
268
+ <div style="font-size:12px; color:#27ae60;">${score}% Similarity</div>
269
+ </div>
270
+ `;
271
+ visualResults.appendChild(el);
272
+ });
273
+
274
+ } catch (e) {
275
+ visualLoader.style.display = 'none';
276
+ visualResults.innerHTML = '<p style="color:red;">Connection Failed</p>';
277
+ }
278
+ };
279
 
280
+ // Initial Hello
281
+ setTimeout(() => addMsg("Hi! I can help you find products by chatting or uploading an image.", "bot"), 1000);
 
 
282
 
283
  })();