Spaces:
Sleeping
Sleeping
USAMA BHATTI commited on
Commit ·
ba2fc46
1
Parent(s): a833774
Feat: Added Visual Search, API Key Auth, and Docker Optimization
Browse files- .dockerignore +1 -0
- .gitignore +2 -0
- Dockerfile +1 -0
- Procfile +1 -0
- backend/src/api/routes/auth.py +1 -0
- backend/src/api/routes/chat.py +102 -16
- backend/src/api/routes/deps.py +91 -3
- backend/src/api/routes/ingestion.py +1 -0
- backend/src/api/routes/settings.py +1 -1
- backend/src/api/routes/visual.py +456 -0
- backend/src/core/config.py +1 -0
- backend/src/db/session.py +27 -9
- backend/src/init_db.py +1 -0
- backend/src/main.py +11 -1
- backend/src/models/ingestion.py +1 -0
- backend/src/models/integration.py +1 -1
- backend/src/models/user.py +1 -0
- backend/src/schemas/chat.py +1 -0
- backend/src/services/chat_service.py +52 -23
- backend/src/services/connectors/base.py +1 -0
- backend/src/services/connectors/cms_base.py +1 -0
- backend/src/services/connectors/mongo_connector.py +1 -1
- backend/src/services/connectors/sanity_connector.py +1 -1
- backend/src/services/connectors/shopify_connector.py +70 -0
- backend/src/services/connectors/woocommerce_connector.py +76 -0
- backend/src/services/ingestion/crawler.py +1 -0
- backend/src/services/ingestion/file_processor.py +1 -0
- backend/src/services/ingestion/guardrail_factory.py +1 -0
- backend/src/services/ingestion/web_processor.py +1 -0
- backend/src/services/ingestion/zip_processor.py +1 -0
- backend/src/services/llm/factory.py +1 -1
- backend/src/services/routing/semantic_router.py +1 -0
- backend/src/services/security/pii_scrubber.py +1 -0
- backend/src/services/tools/cms_agent.py +1 -1
- backend/src/services/tools/cms_tool.py +1 -1
- backend/src/services/tools/nosql_agent.py +1 -1
- backend/src/services/tools/nosql_tool.py +1 -1
- backend/src/services/tools/secure_agent.py +1 -1
- backend/src/services/tools/sql_tool.py +1 -1
- backend/src/services/visual/agent.py +524 -0
- backend/src/services/visual/engine.py +74 -0
- backend/src/update_db.py +57 -0
- backend/src/utils/auth.py +1 -0
- backend/src/utils/security.py +42 -11
- docker-compose.yml +1 -0
- frontend/visual_search_test.html +68 -0
- frontend/visual_search_test.js +132 -0
- requirements.txt +8 -3
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
client_origin = request.headers.get("origin") or request.headers.get("referer") or ""
|
| 29 |
|
| 30 |
-
if
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
| 38 |
|
| 39 |
-
# 3.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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=
|
| 18 |
-
max_overflow=
|
| 19 |
-
pool_recycle=300,
|
| 20 |
-
pool_pre_ping=True,
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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)
|
| 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()
|
| 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"❌
|
| 155 |
# response_text = ""
|
| 156 |
|
| 157 |
-
# # 6. Fallback / RAG (
|
| 158 |
# if not response_text:
|
| 159 |
-
# print("👉 [Router]
|
| 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 |
-
# # --- 🔥
|
| 175 |
# system_instruction = f"""
|
| 176 |
-
# IDENTITY:
|
| 177 |
-
#
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
# CONTEXT FROM KNOWLEDGE BASE:
|
| 180 |
-
#
|
| 181 |
-
|
| 182 |
-
#
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
| 2 |
from cryptography.fernet import Fernet
|
| 3 |
-
import
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
class SecurityUtils:
|
| 10 |
@staticmethod
|
| 11 |
def get_cipher():
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
@staticmethod
|
| 17 |
def encrypt(data: str) -> str:
|
|
|
|
| 18 |
if not data: return ""
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 171 |
-
torchvision==0.24.1
|
| 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 |
-
//
|
| 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") || "#
|
| 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:
|
| 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-
|
| 36 |
-
display: none; width:
|
| 37 |
-
box-shadow: 0 12px 40px rgba(0,0,0,0.
|
| 38 |
-
margin-bottom: 20px;
|
| 39 |
}
|
| 40 |
|
| 41 |
@keyframes omniSlideUp {
|
|
@@ -43,135 +43,241 @@
|
|
| 43 |
to { opacity: 1; transform: translateY(0); }
|
| 44 |
}
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
-
#omni-
|
| 51 |
-
#omni-input
|
| 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-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 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.
|
| 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-
|
| 87 |
<div id="omni-header">
|
| 88 |
-
<
|
| 89 |
-
|
| 90 |
-
<span
|
| 91 |
</div>
|
| 92 |
-
<span style="cursor:pointer; font-size: 24px; font-weight:300;" onclick="window.toggleOmniChat()">×</span>
|
| 93 |
</div>
|
| 94 |
-
|
| 95 |
-
<div
|
| 96 |
-
<
|
| 97 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
-
<button id="omni-chat-btn" onclick="window.
|
| 101 |
`;
|
| 102 |
|
| 103 |
document.body.appendChild(container);
|
| 104 |
|
| 105 |
// ----------------------------------------------------
|
| 106 |
-
//
|
| 107 |
// ----------------------------------------------------
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
| 111 |
|
| 112 |
-
function
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
async function sendMessage() {
|
| 122 |
-
const text =
|
| 123 |
if (!text) return;
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
|
| 128 |
-
// Loading dots logic
|
| 129 |
const loadingDiv = document.createElement('div');
|
| 130 |
loadingDiv.className = 'omni-msg bot';
|
| 131 |
-
loadingDiv.innerHTML = '
|
| 132 |
-
|
| 133 |
-
|
| 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 //
|
| 145 |
})
|
| 146 |
});
|
| 147 |
-
|
|
|
|
| 148 |
const data = await response.json();
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 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 |
-
|
| 157 |
}
|
| 158 |
|
| 159 |
-
} catch (
|
| 160 |
-
if
|
| 161 |
-
|
| 162 |
-
console.error("OmniAgent API Error:", error);
|
| 163 |
}
|
| 164 |
}
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
-
// Initial
|
| 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 |
})();
|