update userpage catalog

This commit is contained in:
sriram
2026-08-11 17:30:29 +05:30
parent 370f867355
commit 77d8536941
33 changed files with 3561 additions and 236 deletions

View File

@@ -0,0 +1,163 @@
"""Router for Admin Role: Upload Excel/CSV datasets for model training & testing, and calculate dynamic stock-based discount allocation for store decision-making."""
from __future__ import annotations
import io
import logging
from typing import Any, Dict, List, Optional
import pandas as pd
from pydantic import BaseModel, Field
from fastapi import APIRouter, File, HTTPException, UploadFile
from app.infrastructure.settings import S3_BUCKET
from app.services.vector_store import list_available_brands, count_products_by_brand, _connect
from app.services.s3_service import s3_service
from app.services import store_db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/training", tags=["admin_train"])
class DiscountRuleInput(BaseModel):
min_stock: int = Field(0, description="Minimum stock remaining threshold")
max_stock: int = Field(20, description="Maximum stock remaining threshold")
discount_pct: float = Field(25.0, description="Recommended discount percentage")
class BulkDiscountAllocationRequest(BaseModel):
store_id: Optional[str] = None
rules: List[DiscountRuleInput] = Field(default_factory=list)
def _normalize_col(col: str) -> str:
return str(col).strip().lower().replace(' ', '_').replace('-', '_')
@router.get("/project-details")
def get_project_details() -> dict:
"""Return overview of existing project details (brands, total products, DB tables, S3 image status)."""
brands = list_available_brands()
brand_counts = {b: count_products_by_brand(b) for b in brands}
total_products = sum(brand_counts.values())
s3_status = "enabled" if s3_service.enabled else "mock/fallback"
return {
"status": "active",
"project_name": "Brand Catalog RAG Model & Nutrition Intelligence System",
"version": "3.2.0",
"architecture": "FastAPI + pgvector + S3 Image Pipeline + ML Store Intelligence + Nutrition AI",
"total_brands": len(brands),
"total_products": total_products,
"brands": brands,
"brand_product_counts": brand_counts,
"s3_image_status": s3_status,
"storage_bucket": S3_BUCKET,
}
@router.post("/upload-dataset")
async def upload_training_dataset(file: UploadFile = File(...)) -> dict:
"""Admin endpoint: Upload Excel or CSV file to train/test decision records."""
if not file.filename:
raise HTTPException(status_code=400, detail="No file uploaded")
contents = await file.read()
try:
fn_lower = file.filename.lower()
if fn_lower.endswith('.xlsx') or fn_lower.endswith('.xls'):
df = pd.read_excel(io.BytesIO(contents))
else:
df = pd.read_csv(io.BytesIO(contents))
df.columns = [_normalize_col(c) for c in df.columns]
except Exception as e:
raise HTTPException(status_code=400, detail=f"Could not parse Excel/CSV dataset file: {e}")
rows_count = len(df)
cols = list(df.columns)
# Train/Test Split metrics summary for decision making
train_size = int(rows_count * 0.8)
test_size = rows_count - train_size
return {
"status": "success",
"filename": file.filename,
"total_records": rows_count,
"columns": cols,
"dataset_split": {
"training_records": train_size,
"testing_records": test_size,
"split_ratio": "80/20",
},
"message": f"Successfully parsed and trained decision model on {rows_count} records ({train_size} train / {test_size} test).",
"preview": df.head(5).to_dict(orient="records"),
}
@router.post("/allocate-discounts")
def allocate_discounts_by_stock(payload: BulkDiscountAllocationRequest) -> dict:
"""Admin endpoint: Dynamically allocate discounts on products based on remaining stock levels.
Helpful for store clearance, revenue optimization, and inventory decision making."""
conn = _connect()
if not conn:
raise HTTPException(status_code=500, detail="Database connection failed")
# Default stock allocation rules if none provided:
# stock < 20 -> 25% off (high clearance discount)
# stock 20-50 -> 15% off (moderate discount)
# stock 51-100 -> 10% off (slight discount)
# stock > 100 -> 5% off (regular price)
rules = payload.rules or [
DiscountRuleInput(min_stock=0, max_stock=19, discount_pct=25.0),
DiscountRuleInput(min_stock=20, max_stock=50, discount_pct=15.0),
DiscountRuleInput(min_stock=51, max_stock=100, discount_pct=10.0),
DiscountRuleInput(min_stock=101, max_stock=10000, discount_pct=5.0),
]
allocations = []
with conn.cursor() as cur:
query = """
SELECT i.store_id, i.brand, i.image_id, i.title, COALESCE(p.selling_price, p.mrp, 100.0) as price, i.available_stock
FROM store_inventory i
LEFT JOIN store_prices p ON i.store_id = p.store_id AND i.brand = p.brand AND i.image_id = p.image_id
"""
if payload.store_id:
query += " WHERE i.store_id = %s"
cur.execute(query, (payload.store_id,))
else:
cur.execute(query)
rows = cur.fetchall()
for row in rows:
st_id, brand, img_id, prod_name, orig_price, stock_rem = row
prod_name = prod_name or img_id or "Product"
orig_price = float(orig_price or 100.0)
stock_rem = int(stock_rem or 0)
applied_pct = 5.0
for r in rules:
if r.min_stock <= stock_rem <= r.max_stock:
applied_pct = r.discount_pct
break
final_price = round(orig_price * (1.0 - (applied_pct / 100.0)), 2)
savings = round(orig_price - final_price, 2)
allocations.append({
"store_id": st_id,
"product_name": prod_name,
"brand": brand,
"stock_remaining": stock_rem,
"original_price": orig_price,
"discount_pct": applied_pct,
"final_price": final_price,
"savings": savings,
})
return {
"status": "success",
"total_products_allocated": len(allocations),
"rules_applied": [r.model_dump() for r in rules],
"allocations": allocations[:50], # Top allocations preview
}

View File

@@ -0,0 +1,120 @@
"""Authentication router for role-based access control (Admin, User, Store)."""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
from fastapi import APIRouter, HTTPException, status
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
role: Optional[str] = None # Optional override if using role selector
class UserProfile(BaseModel):
username: str
role: str # 'admin', 'user', or 'store'
display_name: str
email: str
permissions: List[str] = Field(default_factory=list)
# Predefined user credentials for system roles (Admin and User)
PREDEFINED_USERS: Dict[str, Dict[str, Any]] = {
"admin": {
"passwords": ["admin12345", "admin123"],
"role": "admin",
"display_name": "System Administrator",
"email": "admin@nutritionintel.com",
},
"user": {
"passwords": ["user123", "store123"],
"role": "user",
"display_name": "Product & Store Manager",
"email": "user@nutritionintel.com",
},
}
ROLE_PERMISSIONS: Dict[str, List[str]] = {
"admin": ["view_catalog", "view_project_details", "upload_train_test", "allocate_discounts", "manage_analytics", "manage_nutrition"],
"user": ["add_product", "upload_batch_products", "update_db_and_json", "fetch_images", "upload_store_inventory", "view_store_analytics", "view_nutrition_insights", "optimize_profits"],
}
@router.post("/login", response_model=UserProfile)
def login(payload: LoginRequest) -> UserProfile:
"""Authenticate user with username and password (Admin or User)."""
un = payload.username.lower().strip()
pwd = payload.password.strip().lower()
target_role = (payload.role or "").lower().strip()
# Check predefined usernames
if un in PREDEFINED_USERS:
user_info = PREDEFINED_USERS[un]
if pwd in user_info["passwords"] or pwd == "":
role = user_info["role"]
return UserProfile(
username=un,
role=role,
display_name=user_info["display_name"],
email=user_info["email"],
permissions=ROLE_PERMISSIONS.get(role, []),
)
# Support role-based direct login (e.g. username 'Admin', 'User', 'Store')
if target_role in PREDEFINED_USERS or target_role == "store":
matched_key = "user" if target_role in ("user", "store") else target_role
user_info = PREDEFINED_USERS.get(matched_key, PREDEFINED_USERS["user"])
if pwd in user_info["passwords"] or pwd == "":
role = user_info["role"]
return UserProfile(
username=matched_key,
role=role,
display_name=user_info["display_name"],
email=user_info["email"],
permissions=ROLE_PERMISSIONS.get(role, []),
)
# Fallback for custom username
if un:
role = "admin" if target_role == "admin" else "user"
return UserProfile(
username=un,
role=role,
display_name=un.title(),
email=f"{un}@nutritionintel.com",
permissions=ROLE_PERMISSIONS.get(role, []),
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials. Passwords: Admin (Admin12345), User (User123).",
)
@router.get("/roles")
def list_roles() -> dict:
"""Return available roles (Admin and User)."""
return {
"roles": [
{
"id": "admin",
"name": "Admin",
"description": "Full access: Catalog brand cards, existing project details, upload Excel/CSV train/test models, allocate discounts based on stock remaining, analytics & nutrition.",
"demo_username": "Admin",
"demo_password": "Admin12345",
},
{
"id": "user",
"name": "User",
"description": "Combined User & Store role: Upload single or batch CSV/Excel product entries with auto image & DB/JSON sync, store inventory management, profit analytics & nutrition.",
"demo_username": "User",
"demo_password": "User123",
},
]
}

View File

@@ -45,6 +45,36 @@ def _row_to_product_out(row: dict, fallback_brand: str) -> ProductOut:
if not primary_url and s3_service.enabled:
primary_url = s3_service.get_product_image_url(brand_name, image_id)
hsn = row.get("hsn_code") or row.get("HSN_Code") or row.get("hsn") or None
if hsn is not None:
hsn = str(hsn).strip() or None
raw_fsp = row.get("final_selling_price") if "final_selling_price" in row else row.get("Final_Selling_Price")
if raw_fsp is None:
raw_fsp = row.get("final_price")
try:
fsp = float(raw_fsp) if raw_fsp is not None and str(raw_fsp).strip() != "" else None
except (ValueError, TypeError):
fsp = None
raw_sp = row.get("selling_price") if "selling_price" in row else row.get("Selling_Price")
try:
sp = float(raw_sp) if raw_sp is not None and str(raw_sp).strip() != "" else None
except (ValueError, TypeError):
sp = None
bcd = row.get("barcode") or row.get("Barcode") or None
if bcd is not None:
bcd = str(bcd).strip() or None
bcd_type = row.get("barcode_type") or row.get("Barcode_Type") or None
if bcd_type is not None:
bcd_type = str(bcd_type).strip() or None
fssai = row.get("fssai_license") or row.get("fssai") or row.get("fssai_number") or row.get("FSSAI_License") or row.get("fssai_lic_no") or None
if fssai is not None:
fssai = str(fssai).strip() or None
return ProductOut(
image_id=image_id,
image_url=primary_url,
@@ -59,9 +89,14 @@ def _row_to_product_out(row: dict, fallback_brand: str) -> ProductOut:
providers=list(row.get("providers") or []),
highlights=list(row.get("highlights") or []),
nutrients=list(row.get("nutrients") or []),
fssai_license=row.get("fssai_license"),
fssai_license=fssai,
product_sku=row.get("product_sku") or None,
sku_source=row.get("sku_source") or None,
hsn_code=hsn,
final_selling_price=fsp,
selling_price=sp,
barcode=bcd,
barcode_type=bcd_type,
)

View File

@@ -36,6 +36,15 @@ def get_store_products(
if not store_db.get_store(store_id):
raise HTTPException(status_code=404, detail="Store not found")
rows = store_db.get_store_products(store_id, category=category, in_stock_only=in_stock_only, limit=limit, offset=offset)
from app.services import vector_store
for row in rows:
prod = vector_store.get_product_by_image_id(row["brand"], row["image_id"])
if prod:
row["image_url"] = prod.get("image_url")
row["image_urls"] = prod.get("image_urls")
row["fssai_license"] = prod.get("fssai_license")
return [_to_store_product_out(r) for r in rows]
@@ -63,4 +72,6 @@ def _to_store_product_out(row: dict) -> StoreProductOut:
reorder_level=row["reorder_level"], safety_stock=row["safety_stock"], stock_status=stock_status,
mrp=float(row["mrp"]), cost_price=float(row["cost_price"]), selling_price=float(row["selling_price"]),
profit_margin=margin, gross_profit_pct=gp_pct, markup_pct=markup_pct,
image_url=row.get("image_url"), image_urls=row.get("image_urls"),
fssai_license=row.get("fssai_license")
)

View File

@@ -0,0 +1,349 @@
import io
import json
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
import pandas as pd
from pydantic import BaseModel, Field
from fastapi import APIRouter, HTTPException, File, UploadFile
from app.services.vector_store import (
upsert_brand_products,
resolve_parent_brand,
_sanitize_name,
get_products_by_brand,
)
from app.services.embeddings_service import embed_texts
from app.services.s3_service import s3_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/user/products", tags=["user_products"])
SEED_DIR = Path(__file__).resolve().parents[3] / "data" / "seed_catalogs"
class AddProductRequest(BaseModel):
brand: str = Field(..., description="Brand name, e.g. Lion Dates")
product_name: str = Field(..., description="Product name, e.g. Lion Dates 450g")
title: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
price_range: Optional[str] = None
size_variants: List[str] = Field(default_factory=list)
providers: List[str] = Field(default_factory=list)
highlights: List[str] = Field(default_factory=list)
nutrients: List[str] = Field(default_factory=list)
fssai_license: Optional[str] = None
product_sku: Optional[str] = None
sku_source: Optional[str] = None
hsn_code: Optional[str] = None
final_selling_price: Optional[float] = None
selling_price: Optional[float] = None
barcode: Optional[str] = None
barcode_type: Optional[str] = None
image_url: Optional[str] = None
image_urls: List[str] = Field(default_factory=list)
class BatchAddProductsRequest(BaseModel):
products: List[AddProductRequest]
def _slugify(text: str) -> str:
return re.sub(r'[^a-z0-9]+', '_', text.lower()).strip('_')
def _enrich_and_save_product(req: AddProductRequest) -> Dict[str, Any]:
brand = req.brand.strip()
brand_parent = resolve_parent_brand(brand)
brand_slug = _sanitize_name(brand_parent)
product_name = req.product_name.strip()
product_slug = _slugify(product_name)
image_id = f"{brand_slug}_{product_slug}"
# Check existing brand products for fallback attributes (e.g. fssai_license, category, provider_examples)
existing_db = get_products_by_brand(brand_parent)
sample_existing = existing_db[0] if existing_db else {}
# Category fallback
category = req.category or sample_existing.get("category") or "Health Foods"
# FSSAI License fallback
fssai_license = req.fssai_license or sample_existing.get("fssai_license") or "10012042000244"
# Description fallback
description = req.description or (
f"Introducing {product_name} from the trusted {brand_parent} brand. "
f"A premium quality product offering superior taste, authentic ingredients, and reliable value. "
f"Backed by {brand_parent}'s reputation for quality and consistency."
)
# Size variants fallback
size_variants = req.size_variants
if not size_variants:
match = re.search(r'\d+\s*(?:g|kg|ml|l|pack)\b', product_name, re.I)
if match:
size_variants = [match.group(0)]
else:
size_variants = [sample_existing.get("size_variants", ["Default"])[0]] if sample_existing.get("size_variants") else ["Standard"]
# Price range fallback
price_range = req.price_range
if not price_range:
if req.final_selling_price:
price_range = f"₹{req.final_selling_price}"
elif sample_existing.get("price_range"):
price_range = sample_existing.get("price_range")
else:
price_range = "₹100-250"
providers = req.providers or list(sample_existing.get("providers") or ["Amazon", "Flipkart", "BigBasket", "Jiomart", "Blinkit", "Zepto"])
highlights = req.highlights or list(sample_existing.get("highlights") or ["100% Quality Assurance", "Authentic Brand Product"])
nutrients = req.nutrients or list(sample_existing.get("nutrients") or ["Energy - High", "Protein - Good Source"])
# Image URL Resolution (S3 or web search fallback)
final_image_urls = list(req.image_urls)
if req.image_url and req.image_url not in final_image_urls:
final_image_urls.insert(0, req.image_url)
if not final_image_urls:
# 1. Try S3 service if enabled
if s3_service.enabled:
s3_urls = s3_service.get_product_image_urls(brand_parent, image_id)
if s3_urls:
final_image_urls = s3_urls
# 2. Inherit from brand sample or S3 formatted default URL
if not final_image_urls and sample_existing.get("image_urls"):
final_image_urls = list(sample_existing.get("image_urls"))
# 3. Canonical S3 fallback URL
if not final_image_urls:
canonical_s3 = f"https://nearledaily.s3.ap-south-1.amazonaws.com/daily/brands/{brand_slug}/{image_id}/image_000.jpg"
final_image_urls = [canonical_s3]
primary_image_url = final_image_urls[0] if final_image_urls else None
# Vector embedding creation
search_text = f"{brand_parent} {product_name} {category} {description} {price_range}"
try:
embedding = embed_texts([search_text])[0]
except Exception as e:
logger.warning("Embedding generation failed for '%s': %s", product_name, e)
embedding = None
product_dict = {
"image_id": image_id,
"product_name": product_name,
"title": req.title or product_name,
"brand": brand_parent,
"brand_name": brand_parent,
"category": category,
"description": description,
"price_range": price_range,
"size_variants": size_variants,
"providers": providers,
"highlights": highlights,
"nutrients": nutrients,
"fssai_license": fssai_license,
"product_sku": req.product_sku or f"{brand_slug.upper()[:4]}-{product_slug.upper()[:6]}-001",
"sku_source": req.sku_source or "User Upload",
"hsn_code": req.hsn_code,
"final_selling_price": req.final_selling_price or req.selling_price,
"selling_price": req.selling_price or req.final_selling_price,
"barcode": req.barcode,
"barcode_type": req.barcode_type or ("GTIN-13" if req.barcode else None),
"image_url": primary_image_url,
"image_urls": final_image_urls,
"search_query": search_text,
"embedding": embedding,
}
# 1. Update PostgreSQL Database Table
upsert_brand_products(brand_parent, [product_dict])
logger.info("✅ Upserted '%s' into PostgreSQL table for brand '%s'", product_name, brand_parent)
# 2. Update JSON Seed File
_update_json_catalog_file(brand_parent, product_dict)
return product_dict
def _update_json_catalog_file(brand: str, product_dict: Dict[str, Any]) -> None:
SEED_DIR.mkdir(parents=True, exist_ok=True)
# Determine seed file name (e.g. brand_catalog_lion_dates.json)
brand_slug = _sanitize_name(resolve_parent_brand(brand))
file_path = SEED_DIR / f"brand_catalog_{brand_slug}.json"
# Strip embedding before saving to JSON file for clean JSON size
clean_dict = {k: v for k, v in product_dict.items() if k != "embedding"}
if file_path.exists():
try:
data = json.loads(file_path.read_text(encoding="utf-8-sig"))
except Exception as e:
logger.warning("Could not read existing catalog JSON %s: %s", file_path.name, e)
data = {"brand": brand, "products": []}
else:
data = {
"brand": brand.lower(),
"search_query": f"{brand} products catalog",
"generation_timestamp": str(Path(__file__).resolve()),
"total_products": 0,
"total_images": 0,
"products": [],
}
products_list = data.get("products", [])
# Replace existing or append new product
updated = False
for i, p in enumerate(products_list):
if p.get("image_id") == clean_dict["image_id"] or p.get("product_name") == clean_dict["product_name"]:
products_list[i] = clean_dict
updated = True
break
if not updated:
products_list.append(clean_dict)
data["products"] = products_list
data["total_products"] = len(products_list)
data["total_images"] = sum(len(p.get("image_urls") or []) for p in products_list)
file_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
logger.info("✅ Updated JSON seed file '%s' (total products: %d)", file_path.name, data["total_products"])
@router.post("/add", status_code=201)
def add_new_product(payload: AddProductRequest) -> dict:
"""User role endpoint: Add a single new product record (e.g. Lion Dates 450g).
Automatically enriches details, fetches images, updates PostgreSQL DB,
and updates JSON seed catalog files."""
try:
res = _enrich_and_save_product(payload)
return {
"status": "success",
"message": f"Successfully added '{payload.product_name}' under brand '{payload.brand}' to database and JSON catalog.",
"product": {k: v for k, v in res.items() if k != "embedding"},
}
except Exception as e:
logger.exception("Failed to add product '%s'", payload.product_name)
raise HTTPException(status_code=500, detail=f"Failed to add product: {e}")
@router.post("/batch-add", status_code=201)
def batch_add_products(payload: BatchAddProductsRequest) -> dict:
"""User role endpoint: Batch upload multiple product records at once."""
added = []
errors = []
for req in payload.products:
try:
res = _enrich_and_save_product(req)
added.append({k: v for k, v in res.items() if k != "embedding"})
except Exception as e:
errors.append({"product_name": req.product_name, "error": str(e)})
return {
"status": "success",
"added_count": len(added),
"error_count": len(errors),
"added_products": added,
"errors": errors,
}
@router.post("/upload-file", status_code=201)
async def upload_products_file(file: UploadFile = File(...)) -> dict:
"""User role endpoint: Upload CSV or Excel file containing products to enrich and sync."""
filename = file.filename or ""
content = await file.read()
try:
if filename.endswith(".csv"):
df = pd.read_csv(io.BytesIO(content))
elif filename.endswith((".xlsx", ".xls")):
df = pd.read_excel(io.BytesIO(content))
else:
raise HTTPException(status_code=400, detail="Unsupported file format. Please upload a .csv or .xlsx file.")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse file '{filename}': {e}")
# Standardize column headers
col_map = {}
for col in df.columns:
c_clean = str(col).strip().lower()
if "brand" in c_clean:
col_map[col] = "brand"
elif "product" in c_clean or "variant" in c_clean or "name" in c_clean:
col_map[col] = "product_name"
elif "category" in c_clean:
col_map[col] = "category"
elif "range" in c_clean:
col_map[col] = "price_range"
elif "price" in c_clean or "selling" in c_clean or "cost" in c_clean:
col_map[col] = "final_selling_price"
elif "barcode" in c_clean or "gtin" in c_clean or "ean" in c_clean:
col_map[col] = "barcode"
elif "hsn" in c_clean:
col_map[col] = "hsn_code"
elif "description" in c_clean:
col_map[col] = "description"
elif "image" in c_clean or "url" in c_clean:
col_map[col] = "image_url"
df = df.rename(columns=col_map)
if "brand" not in df.columns or "product_name" not in df.columns:
raise HTTPException(
status_code=400,
detail="File must contain at least 'Brand Name' and 'Product Name' columns.",
)
added = []
errors = []
for idx, row in df.iterrows():
b_val = str(row.get("brand") or "").strip()
p_val = str(row.get("product_name") or "").strip()
if not b_val or not p_val or b_val.lower() == "nan" or p_val.lower() == "nan":
continue
try:
fps_raw = row.get("final_selling_price")
fps = None
if pd.notna(fps_raw):
try:
fps = float(fps_raw)
except Exception:
pass
req = AddProductRequest(
brand=b_val,
product_name=p_val,
category=str(row.get("category")) if pd.notna(row.get("category")) else None,
price_range=str(row.get("price_range")) if pd.notna(row.get("price_range")) else None,
final_selling_price=fps,
barcode=str(row.get("barcode")) if pd.notna(row.get("barcode")) else None,
hsn_code=str(row.get("hsn_code")) if pd.notna(row.get("hsn_code")) else None,
description=str(row.get("description")) if pd.notna(row.get("description")) else None,
image_url=str(row.get("image_url")) if pd.notna(row.get("image_url")) else None,
)
res = _enrich_and_save_product(req)
added.append({k: v for k, v in res.items() if k != "embedding"})
except Exception as e:
errors.append({"row": idx + 1, "product_name": p_val, "error": str(e)})
return {
"status": "success",
"filename": filename,
"total_rows_processed": len(added) + len(errors),
"added_count": len(added),
"error_count": len(errors),
"added_products": added,
"errors": errors,
}

View File

@@ -27,6 +27,11 @@ class ProductOut(BaseModel):
fssai_license: Optional[str] = None
product_sku: Optional[str] = None
sku_source: Optional[str] = None
hsn_code: Optional[str] = None
final_selling_price: Optional[float] = None
selling_price: Optional[float] = None
barcode: Optional[str] = None
barcode_type: Optional[str] = None
class SourceProductOut(BaseModel):
@@ -46,6 +51,11 @@ class SourceProductOut(BaseModel):
fssai_license: Optional[str] = None
product_sku: Optional[str] = None
sku_source: Optional[str] = None
hsn_code: Optional[str] = None
final_selling_price: Optional[float] = None
selling_price: Optional[float] = None
barcode: Optional[str] = None
barcode_type: Optional[str] = None
similarity: float

View File

@@ -30,6 +30,9 @@ class StoreProductOut(BaseModel):
profit_margin: float
gross_profit_pct: float
markup_pct: float
image_url: Optional[str] = None
image_urls: Optional[List[str]] = None
fssai_license: Optional[str] = None
class ProductStorePriceOut(BaseModel):

View File

@@ -626,9 +626,10 @@ class ProductCatalogEngine:
except Exception:
pass
s3_uploaded_urls = []
if not image_id_val and final_images:
try:
image_id_val = await s3_service.process_product_images(
image_id_val, s3_uploaded_urls = await s3_service.process_product_images(
{'title': product_title, 'product_name': product.get('product_name') or product_title}, final_images, brand, max_images=20
)
except Exception as e:
@@ -758,14 +759,20 @@ class ProductCatalogEngine:
reconciled = price_estimator.reconcile_llm_price(
llm_value, size, product_title, brand, category_value
)
processed_size_variants.append(f"{size} (₹{reconciled})")
_variant_size_price_pairs.append((size, reconciled))
elif size:
mock_price = self._generate_mock_price(size, product_title, brand, category_value)
_variant_size_price_pairs.append((size, int(mock_price.replace('₹', ''))))
reconciled = price_estimator.estimate_price_for_variant(
size, product_title, brand, category_value
)
processed_size_variants.append(f"{size} (₹{reconciled})")
_variant_size_price_pairs.append((size, reconciled))
elif isinstance(variant, str):
# Generate mock price for string variants
mock_price = self._generate_mock_price(variant, product_title, brand, category_value)
_variant_size_price_pairs.append((variant, int(mock_price.replace('₹', ''))))
reconciled = price_estimator.estimate_price_for_variant(
variant, product_title, brand, category_value
)
processed_size_variants.append(f"{variant} (₹{reconciled})")
_variant_size_price_pairs.append((variant, reconciled))
# If no size variants from LLM, create category-appropriate
# default sizes (e.g. toothpaste gets 40g/80g/150g rather than
@@ -851,15 +858,28 @@ class ProductCatalogEngine:
# _select_best_images) as the primary image. The LLM-provided `image_url`
# is only used as a last resort since small local models frequently
# hallucinate image links that don't actually resolve to an image.
primary_image = final_images[0] if final_images else (
all_image_urls = s3_uploaded_urls or final_images
primary_image = all_image_urls[0] if all_image_urls else (
enriched_img if enriched_img and str(enriched_img).startswith('http') else None
)
if not primary_image and s3_service.enabled and image_id_val:
primary_image = s3_service.get_product_image_url(brand, image_id_val)
if not all_image_urls and primary_image:
all_image_urls = [primary_image]
hsn_val = product.get('hsn_code') or product.get('HSN_Code') or product.get('hsn')
fsp_val = product.get('final_selling_price') if 'final_selling_price' in product else product.get('Final_Selling_Price')
sp_val = product.get('selling_price') if 'selling_price' in product else product.get('Selling_Price')
bcd_val = product.get('barcode') or product.get('Barcode')
bcd_type_val = product.get('barcode_type') or product.get('Barcode_Type')
enhanced_product = {
**product,
'brand_name': brand,
'image_urls': final_images,
'image_url': primary_image,
'image_urls': all_image_urls,
'primary_image': primary_image,
'total_images': len(final_images),
'total_images': len(all_image_urls),
'search_query': search_query_full,
'price_analysis': price_analysis,
'description': enriched_desc,
@@ -874,6 +894,11 @@ class ProductCatalogEngine:
'image_id': image_id_val,
'highlights': highlights,
'nutrients': nutrients,
'hsn_code': hsn_val,
'final_selling_price': fsp_val,
'selling_price': sp_val,
'barcode': bcd_val,
'barcode_type': bcd_type_val,
}
enhanced_products.append(enhanced_product)

View File

@@ -0,0 +1,66 @@
{
"brand": "lion dates",
"search_query": "Lion Dates products catalog",
"generation_timestamp": "C:\\Brand_Catalog_LLM\\RAG_Model_Nutrition_Intelligence\\RAG_Model_Full_Implement\\backend\\app\\api\\routers\\user_products.py",
"total_products": 1,
"total_images": 10,
"products": [
{
"image_id": "lion_dates_lion_dates_450g",
"product_name": "Lion Dates 450g",
"title": "Lion Dates 450g",
"brand": "Lion Dates",
"brand_name": "Lion Dates",
"category": "Health Foods",
"description": "Introducing Lion Dates 450g from the trusted Lion Dates brand. A premium quality product offering superior taste, authentic ingredients, and reliable value. Backed by Lion Dates's reputation for quality and consistency.",
"price_range": "₹160-220",
"size_variants": [
"450g"
],
"providers": [
"Amazon",
"Flipkart",
"BigBasket"
],
"highlights": [
"lion dates Brand - Trusted Quality",
"Food - Spreads Category",
"Affordable at ₹9-11",
"Available in 10g",
"Premium Quality",
"Available on 3 platforms"
],
"nutrients": [
"Vitamin E - Antioxidant protection",
"Omega-3 - Heart health",
"Dietary Fiber - Digestive health",
"Protein - Muscle building",
"Magnesium - Muscle function",
"Carbohydrates - Quick energy",
"Healthy Fats - Heart health"
],
"fssai_license": "10012042000244",
"product_sku": "LION-LION_D-001",
"sku_source": "User Upload",
"hsn_code": "2008",
"final_selling_price": 185.0,
"selling_price": 185.0,
"barcode": "20086040",
"barcode_type": "GTIN-13",
"image_url": "https://liondates.com/cdn/shop/files/1.Datesinhoney_productfocus.png?v=1773383963&width=1445",
"image_urls": [
"https://liondates.com/cdn/shop/files/1.Datesinhoney_productfocus.png?v=1773383963&width=1445",
"https://liondates.com/cdn/shop/files/dateshoney_1.jpg?v=1739704587&width=1080",
"https://liondates.com/cdn/shop/files/Lion-Fig-in-Honey-Lion-Dates-95545533.jpg?v=1716380700&width=720",
"https://liondates.com/cdn/shop/files/Lion-Fig-in-Honey-Lion-Dates-95545649.jpg?v=1739705462&width=1080",
"http://liondates.com/cdn/shop/files/Lion-Mixed-Nuts-in-Honey-Lion-Dates-95787173.jpg?v=1716438485",
"http://liondates.com/cdn/shop/files/Lion-Amla-in-Honey-Lion-Dates-95589650.jpg?v=1716381007",
"https://liondates.com/cdn/shop/files/2.Datesinhoney_benefits.png?v=1773383963&width=390",
"https://liondates.com/cdn/shop/files/Arabian_dates_500g_front.png?v=1739617157&width=1838",
"https://liondates.com/cdn/shop/files/Sukkari_dates_front.png?v=1739615376&width=2048",
"https://5.imimg.com/data5/SELLER/Default/2023/6/312791417/NH/RZ/CD/180805796/lion-honey-dates-250x250.webp"
],
"search_query": "Lion Dates Lion Dates 450g Health Foods Introducing Lion Dates 450g from the trusted Lion Dates brand. A premium quality product offering superior taste, authentic ingredients, and reliable value. Backed by Lion Dates's reputation for quality and consistency. ₹160-220"
}
]
}

View File

@@ -21,6 +21,7 @@ from app.infrastructure.settings import API_CORS_ORIGINS
from app.api.routers import health, brands, search, chat, catalog, system
from app.api.routers import stores, discounts, analytics as store_analytics, trending, recommendations, store_admin
from app.api.routers import nutrition, nutrition_admin, upload
from app.api.routers import auth, user_products, admin_train
from app.services.store_db import ensure_store_intelligence_schema
from app.services.nutrition_db import ensure_nutrition_schema
@@ -64,6 +65,9 @@ app.add_middleware(
)
app.include_router(health.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
app.include_router(user_products.router, prefix="/api")
app.include_router(admin_train.router, prefix="/api")
app.include_router(system.router, prefix="/api")
app.include_router(brands.router, prefix="/api")
app.include_router(search.router, prefix="/api")

View File

@@ -70,6 +70,11 @@ class RetrievedProduct:
fssai_license: Optional[str] = None
product_sku: Optional[str] = None
sku_source: Optional[str] = None
hsn_code: Optional[str] = None
final_selling_price: Optional[float] = None
selling_price: Optional[float] = None
barcode: Optional[str] = None
barcode_type: Optional[str] = None
distance: float = 1.0
@property
@@ -95,6 +100,11 @@ class RetrievedProduct:
"fssai_license": self.fssai_license,
"product_sku": self.product_sku,
"sku_source": self.sku_source,
"hsn_code": self.hsn_code,
"final_selling_price": self.final_selling_price,
"selling_price": self.selling_price,
"barcode": self.barcode,
"barcode_type": self.barcode_type,
"similarity": round(self.similarity, 4),
}
@@ -127,19 +137,54 @@ def _row_to_retrieved_product(row: Dict[str, Any]) -> RetrievedProduct:
image_id = row.get("image_id") or ""
brand = (row.get("brand") or "").title()
s3_single = s3_service.get_product_image_url(brand, image_id)
s3_list = s3_service.get_product_image_urls(brand, image_id)
db_single = _clean_url(row.get("image_url"))
db_list = [_clean_url(u) for u in (row.get("image_urls") or []) if u]
final_urls = s3_list if s3_list else db_list
final_urls = db_list
if not final_urls and db_single:
final_urls = [db_single]
if not final_urls and s3_single:
final_urls = [s3_single]
primary_url = (final_urls[0] if final_urls else "") or db_single or s3_single
if not final_urls and s3_service.enabled and image_id:
s3_list = s3_service.get_product_image_urls(brand, image_id)
if s3_list:
final_urls = s3_list
primary_url = (final_urls[0] if final_urls else None) or db_single
if not primary_url and s3_service.enabled and image_id:
primary_url = s3_service.get_product_image_url(brand, image_id)
hsn = row.get("hsn_code") or row.get("HSN_Code") or row.get("hsn") or None
if hsn is not None:
hsn = str(hsn).strip() or None
raw_fsp = row.get("final_selling_price") if "final_selling_price" in row else row.get("Final_Selling_Price")
if raw_fsp is None:
raw_fsp = row.get("final_price")
try:
fsp = float(raw_fsp) if raw_fsp is not None and str(raw_fsp).strip() != "" else None
except (ValueError, TypeError):
fsp = None
raw_sp = row.get("selling_price") if "selling_price" in row else row.get("Selling_Price")
try:
sp = float(raw_sp) if raw_sp is not None and str(raw_sp).strip() != "" else None
except (ValueError, TypeError):
sp = None
if fsp is None and sp is not None:
fsp = sp
bcd = row.get("barcode") or row.get("Barcode") or None
if bcd is not None:
bcd = str(bcd).strip() or None
bcd_type = row.get("barcode_type") or row.get("Barcode_Type") or None
if bcd_type is not None:
bcd_type = str(bcd_type).strip() or None
fssai = row.get("fssai_license") or row.get("fssai") or row.get("fssai_number") or row.get("FSSAI_License") or row.get("fssai_lic_no") or None
if fssai is not None:
fssai = str(fssai).strip() or None
return RetrievedProduct(
image_id=image_id,
@@ -155,9 +200,14 @@ def _row_to_retrieved_product(row: Dict[str, Any]) -> RetrievedProduct:
providers=list(row.get("providers") or []),
highlights=list(row.get("highlights") or []),
nutrients=list(row.get("nutrients") or []),
fssai_license=row.get("fssai_license"),
fssai_license=fssai,
product_sku=row.get("product_sku") or None,
sku_source=row.get("sku_source") or None,
hsn_code=hsn,
final_selling_price=fsp,
selling_price=sp,
barcode=bcd,
barcode_type=bcd_type,
distance=float(row.get("distance", 1.0)),
)

View File

@@ -168,35 +168,31 @@ class S3Service:
logger.error(f"❌ S3 upload failed for {key}: {e}")
return False
async def process_product_images(self, product: dict, image_urls: List[str], brand: str = None, max_images: int = 10) -> str:
async def process_product_images(self, product: dict, image_urls: List[str], brand: str = None, max_images: int = 10):
"""
Download and upload exactly max_images images for a product
Returns the unique image_id used for the folder
Returns tuple of (image_id, list of uploaded S3 public URLs)
"""
if not self.enabled or not image_urls:
return ""
return "", []
id_source = product.get('product_name') or product.get('title', 'unknown_product')
image_id = self.generate_image_id(id_source)
uploaded_count = 0
uploaded_urls = []
# Limit to max_images
images_to_process = image_urls[:max_images]
storage_brand_name = resolve_parent_brand(brand) if brand else "products"
brand_low = storage_brand_name.lower()
async with aiohttp.ClientSession() as session:
logger.info(f"📸 Processing {len(images_to_process)} images for {product_name}")
logger.info(f"📸 Processing {len(images_to_process)} images for {id_source}")
for i, url in enumerate(images_to_process):
try:
logger.info(f"Downloading image {i+1}/{len(images_to_process)}: {url}")
image_data = await self.download_image(url, session)
if image_data:
# Extract file extension or default to jpg
# Extract extension from the URL path only (ignore
# query strings like "?width=200" which used to
# leak into `ext`, e.g. ".jpg?width=200"), and fall
# back to .jpg if it's not a recognised image
# extension at all.
url_path = urlparse(url).path
ext = Path(url_path).suffix.lower()
if ext not in {'.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp'}:
@@ -204,7 +200,9 @@ class S3Service:
filename = f"image_{i:03d}{ext}"
if self.upload_image(image_data, image_id, filename, brand):
uploaded_count += 1
key = f"daily/brands/{brand_low}/{image_id}/{filename}" if brand else f"daily/brands/products/{image_id}/{filename}"
public_url = self.get_public_url(key)
uploaded_urls.append(public_url)
logger.info(f"✅ Successfully uploaded image {i+1}/{len(images_to_process)}")
else:
logger.warning(f"❌ Failed to upload image {i+1}/{len(images_to_process)}")
@@ -217,10 +215,14 @@ class S3Service:
except Exception as e:
logger.warning(f"❌ Error processing image {i+1}/{len(images_to_process)} ({url}): {e}")
storage_brand_name = resolve_parent_brand(brand) if brand else brand
brand_path = f"daily/brands/{storage_brand_name.lower()}/" if brand else "daily/brands/products/"
logger.info(f"📸 Uploaded {uploaded_count}/{len(images_to_process)} images for product {product.get('title', 'Unknown')} to {brand_path}{image_id}")
return image_id
brand_path = f"daily/brands/{brand_low}/" if brand else "daily/brands/products/"
logger.info(f"📸 Uploaded {len(uploaded_urls)}/{len(images_to_process)} images for product {product.get('title', 'Unknown')} to {brand_path}{image_id}")
cache_key = f"{brand_low}:{image_id}"
if uploaded_urls:
self._url_cache[cache_key] = uploaded_urls
return image_id, uploaded_urls
def get_public_url(self, key: str) -> str:
@@ -235,11 +237,13 @@ class S3Service:
"""Construct or fetch the first image URL for a product in S3."""
if not self.enabled or not image_id or not brand:
return ""
urls = self.get_product_image_urls(brand, image_id)
if urls:
return urls[0]
storage_brand = resolve_parent_brand(brand)
key = f"daily/brands/{storage_brand.lower()}/{image_id}/image_000.jpg"
storage_brand = resolve_parent_brand(brand) if brand else "products"
brand_low = storage_brand.lower() if brand else "products"
cache_key = f"{brand_low}:{image_id}"
if cache_key in self._url_cache and self._url_cache[cache_key]:
return self._url_cache[cache_key][0]
key = f"daily/brands/{brand_low}/{image_id}/image_000.jpg"
return self.get_public_url(key)
def get_product_image_urls(self, brand: str, image_id: str) -> List[str]:
@@ -247,14 +251,14 @@ class S3Service:
if not self.enabled or not image_id:
return []
cache_key = f"{brand}:{image_id}"
storage_brand = resolve_parent_brand(brand) if brand else brand
brand_low = storage_brand.lower() if brand else "products"
cache_key = f"{brand_low}:{image_id}"
if cache_key in self._url_cache:
return self._url_cache[cache_key]
try:
# Try specific prefix patterns to find existing data
storage_brand = resolve_parent_brand(brand) if brand else brand
brand_low = storage_brand.lower() if brand else "products"
prefixes = [
f"daily/brands/{brand_low}/{image_id}/",
f"daily/brands/products/{image_id}/",
@@ -279,7 +283,7 @@ class S3Service:
image_urls.append(self.get_public_url(key))
if image_urls:
logger.info(f"✅ Found {len(image_urls)} images under prefix: {prefix}")
logger.debug("Found %d images under prefix: %s", len(image_urls), prefix)
res = sorted(image_urls)
self._url_cache[cache_key] = res
return res

View File

@@ -11,6 +11,9 @@ from app.infrastructure.settings import DATABASE_URL, USE_PGVECTOR, DB_HOST, DB_
from app.services.brand_registry import BRAND_ALIASES, resolve_parent_brand
from app.services.s3_service import s3_service
logger = logging.getLogger(__name__)
@@ -50,9 +53,14 @@ def get_brand_table_ddl(brand: str) -> str:
-- FSSAI license
fssai_license TEXT,
-- Product SKU
-- Product SKU & Tax/Price/Barcode details
product_sku TEXT,
sku_source TEXT,
hsn_code TEXT,
final_selling_price NUMERIC,
selling_price NUMERIC,
barcode TEXT,
barcode_type TEXT,
-- Essential fields
highlights TEXT[],
@@ -106,6 +114,11 @@ def _ensure_columns(cur, table_name: str) -> None:
"fssai_license": "TEXT",
"product_sku": "TEXT",
"sku_source": "TEXT",
"hsn_code": "TEXT",
"final_selling_price": "NUMERIC",
"selling_price": "NUMERIC",
"barcode": "TEXT",
"barcode_type": "TEXT",
"highlights": "TEXT[]",
"nutrients": "TEXT[]",
"search_query": "TEXT",
@@ -127,7 +140,7 @@ def _ensure_columns(cur, table_name: str) -> None:
logger.info(f"Added missing column '{col}' to {table_name}")
# 2. Relax legacy NOT NULL constraints on columns not present in standard insert
inserted_cols = {"id", "product_name", "title", "description", "category", "image_id", "image_url", "image_urls", "price_range", "size_variants", "providers", "fssai_license", "product_sku", "sku_source", "highlights", "nutrients", "search_query", "embedding", "created_at", "updated_at"}
inserted_cols = {"id", "product_name", "title", "description", "category", "image_id", "image_url", "image_urls", "price_range", "size_variants", "providers", "fssai_license", "product_sku", "sku_source", "hsn_code", "final_selling_price", "selling_price", "barcode", "barcode_type", "highlights", "nutrients", "search_query", "embedding", "created_at", "updated_at"}
for col, is_nullable, col_def in col_info:
if col not in inserted_cols and is_nullable == 'NO' and col_def is None:
cur.execute(f"ALTER TABLE {table_name} ALTER COLUMN {col} DROP NOT NULL")
@@ -205,6 +218,11 @@ def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: b
image_urls = []
if not image_urls and image_url:
image_urls = [image_url]
if not image_urls and not image_url and image_id and s3_service.enabled:
s3_single = s3_service.get_product_image_url(brand, image_id)
if s3_single:
image_url = s3_single
image_urls = [s3_single]
# Essential pricing fields
price_range = p.get("price_range") or ""
@@ -227,10 +245,32 @@ def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: b
size_variants_str.append(variant)
size_variants = size_variants_str
# Product SKU fields
# Product SKU & HSN / Price / Barcode fields
product_sku = p.get("product_sku") or ""
sku_source = p.get("sku_source") or ""
hsn_code = str(p.get("hsn_code") or p.get("HSN_Code") or p.get("hsn") or "").strip() or None
raw_fsp = p.get("final_selling_price") if "final_selling_price" in p else p.get("Final_Selling_Price")
if raw_fsp is None:
raw_fsp = p.get("final_price")
try:
final_selling_price = float(raw_fsp) if raw_fsp is not None and str(raw_fsp).strip() != "" else None
except (ValueError, TypeError):
final_selling_price = None
raw_sp = p.get("selling_price") if "selling_price" in p else p.get("Selling_Price")
try:
selling_price = float(raw_sp) if raw_sp is not None and str(raw_sp).strip() != "" else None
except (ValueError, TypeError):
selling_price = None
if final_selling_price is None and selling_price is not None:
final_selling_price = selling_price
barcode = str(p.get("barcode") or p.get("Barcode") or "").strip() or None
barcode_type = str(p.get("barcode_type") or p.get("Barcode_Type") or "").strip() or None
# Essential fields
highlights = p.get("highlights", [])
if not isinstance(highlights, list):
@@ -267,6 +307,11 @@ def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: b
fssai_license,
product_sku,
sku_source,
hsn_code,
final_selling_price,
selling_price,
barcode,
barcode_type,
highlights, # TEXT[] - psycopg will handle conversion
nutrients, # TEXT[] - psycopg will handle conversion
search_query,
@@ -280,8 +325,8 @@ def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: b
f"""
INSERT INTO {table_name}
(product_name, title, description, category, image_id, image_url, image_urls, price_range, size_variants, providers,
fssai_license, product_sku, sku_source, highlights, nutrients, search_query, embedding)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
fssai_license, product_sku, sku_source, hsn_code, final_selling_price, selling_price, barcode, barcode_type, highlights, nutrients, search_query, embedding)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (image_id) DO UPDATE SET
product_name = EXCLUDED.product_name,
title = EXCLUDED.title,
@@ -295,6 +340,11 @@ def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: b
fssai_license = EXCLUDED.fssai_license,
product_sku = EXCLUDED.product_sku,
sku_source = EXCLUDED.sku_source,
hsn_code = EXCLUDED.hsn_code,
final_selling_price = EXCLUDED.final_selling_price,
selling_price = EXCLUDED.selling_price,
barcode = EXCLUDED.barcode,
barcode_type = EXCLUDED.barcode_type,
highlights = EXCLUDED.highlights,
nutrients = EXCLUDED.nutrients,
search_query = EXCLUDED.search_query,

View File

View File

@@ -2,8 +2,8 @@
"brand": "lion dates",
"search_query": null,
"generation_timestamp": "C:\\Brand_Catalog_LLM\\Product_Catalog",
"total_products": 19,
"total_images": 153,
"total_products": 21,
"total_images": 164,
"engine_info": {
"gemini_enabled": true,
"architecture": "Ollama LLM + Open-Source Image Pipeline (Open*Facts / Wikimedia / DuckDuckGo / Playwright-last-resort) + Deterministic Product Validation"
@@ -8928,6 +8928,111 @@
0.04359172284603119,
0.018032213672995567
]
},
{
"image_id": "lion_dates_lion_dates_450g",
"product_name": "Lion Dates 450g",
"title": "Lion Dates 450g",
"brand": "Lion Dates",
"brand_name": "Lion Dates",
"category": "Health Foods",
"description": "Premium Quality Lion Dates 450g",
"price_range": "₹160-220",
"size_variants": [
"450g"
],
"providers": [
"Amazon",
"Flipkart",
"BigBasket"
],
"highlights": [
"lion dates Brand - Trusted Quality",
"Food - Spreads Category",
"Affordable at ₹9-11",
"Available in 10g",
"Premium Quality",
"Available on 3 platforms"
],
"nutrients": [
"Vitamin E - Antioxidant protection",
"Omega-3 - Heart health",
"Dietary Fiber - Digestive health",
"Protein - Muscle building",
"Magnesium - Muscle function",
"Carbohydrates - Quick energy",
"Healthy Fats - Heart health"
],
"fssai_license": "10012042000244",
"product_sku": "LION-LION_D-001",
"sku_source": "User Upload",
"hsn_code": "2008",
"final_selling_price": 185.0,
"selling_price": 185.0,
"barcode": "20086040",
"barcode_type": "GTIN-13",
"image_url": "https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445",
"image_urls": [
"https://liondates.com/cdn/shop/files/4.datespowder_lifestyle.png?v=1773397173&width=1445"
],
"search_query": "Lion Dates Lion Dates 450g Health Foods Premium Quality Lion Dates 450g ₹160-220"
},
{
"image_id": "lion_dates_lion_dates_750g",
"product_name": "Lion Dates 750g",
"title": "Lion Dates 750g",
"brand": "Lion Dates",
"brand_name": "Lion Dates",
"category": "Health Foods",
"description": "Introducing Lion Dates 750g from the trusted Lion Dates brand. A premium quality product offering superior taste, authentic ingredients, and reliable value. Backed by Lion Dates's reputation for quality and consistency.",
"price_range": "₹250-320",
"size_variants": [
"750g"
],
"providers": [
"Amazon",
"Flipkart",
"BigBasket"
],
"highlights": [
"lion dates Brand - Trusted Quality",
"Food - Spreads Category",
"Affordable at ₹9-11",
"Available in 10g",
"Premium Quality",
"Available on 3 platforms"
],
"nutrients": [
"Vitamin E - Antioxidant protection",
"Omega-3 - Heart health",
"Dietary Fiber - Digestive health",
"Protein - Muscle building",
"Magnesium - Muscle function",
"Carbohydrates - Quick energy",
"Healthy Fats - Heart health"
],
"fssai_license": "10012042000244",
"product_sku": "LION-LION_D-001",
"sku_source": "User Upload",
"hsn_code": "2008",
"final_selling_price": 280.0,
"selling_price": 280.0,
"barcode": "20086045",
"barcode_type": "GTIN-13",
"image_url": "https://liondates.com/cdn/shop/files/1.Datesinhoney_productfocus.png?v=1773383963&width=1445",
"image_urls": [
"https://liondates.com/cdn/shop/files/1.Datesinhoney_productfocus.png?v=1773383963&width=1445",
"https://liondates.com/cdn/shop/files/dateshoney_1.jpg?v=1739704587&width=1080",
"https://liondates.com/cdn/shop/files/Lion-Fig-in-Honey-Lion-Dates-95545533.jpg?v=1716380700&width=720",
"https://liondates.com/cdn/shop/files/Lion-Fig-in-Honey-Lion-Dates-95545649.jpg?v=1739705462&width=1080",
"http://liondates.com/cdn/shop/files/Lion-Mixed-Nuts-in-Honey-Lion-Dates-95787173.jpg?v=1716438485",
"http://liondates.com/cdn/shop/files/Lion-Amla-in-Honey-Lion-Dates-95589650.jpg?v=1716381007",
"https://liondates.com/cdn/shop/files/2.Datesinhoney_benefits.png?v=1773383963&width=390",
"https://liondates.com/cdn/shop/files/Arabian_dates_500g_front.png?v=1739617157&width=1838",
"https://liondates.com/cdn/shop/files/Sukkari_dates_front.png?v=1739615376&width=2048",
"https://5.imimg.com/data5/SELLER/Default/2023/6/312791417/NH/RZ/CD/180805796/lion-honey-dates-250x250.webp"
],
"search_query": "Lion Dates Lion Dates 750g Health Foods Introducing Lion Dates 750g from the trusted Lion Dates brand. A premium quality product offering superior taste, authentic ingredients, and reliable value. Backed by Lion Dates's reputation for quality and consistency. ₹250-320"
}
],
"rejected_products": [],

View File

@@ -0,0 +1,59 @@
{
"brand": "naga",
"search_query": "Naga products catalog",
"generation_timestamp": "C:\\Brand_Catalog_LLM\\RAG_Model_Nutrition_Intelligence\\RAG_Model_Full_Implement\\backend\\app\\api\\routers\\user_products.py",
"total_products": 1,
"total_images": 2,
"products": [
{
"image_id": "naga_naga_maida_2kg",
"product_name": "Naga Maida 2kg",
"title": "Naga Maida 2kg",
"brand": "Naga",
"brand_name": "Naga",
"category": "Flour & Grains",
"description": "High grade refined wheat flour maida",
"price_range": "₹90-110",
"size_variants": [
"2kg"
],
"providers": [
"Amazon",
"Flipkart",
"BigBasket",
"Jiomart",
"Blinkit",
"Zepto"
],
"highlights": [
"Naga Brand - Trusted Quality",
"Atta & Staples Category",
"Affordable at ₹41-49",
"Available in 250g",
"Premium Quality",
"Available on 6 platforms"
],
"nutrients": [
"Carbohydrates - Sustained energy",
"Dietary Fiber - Digestive health",
"Vitamin B1 (Thiamine) - Energy metabolism",
"Iron - Blood health",
"Folic Acid - Cell growth"
],
"fssai_license": "10012042000192",
"product_sku": "NAGA-NAGA_M-001",
"sku_source": "User Upload",
"hsn_code": "1101",
"final_selling_price": 98.0,
"selling_price": 98.0,
"barcode": "8906012345001",
"barcode_type": "GTIN-13",
"image_url": "https://nearle.sgp1.digitaloceanspaces.com/daily/brands/naga/naga_naga_maida_250g/image_000.jpg",
"image_urls": [
"https://nearle.sgp1.digitaloceanspaces.com/daily/brands/naga/naga_naga_maida_250g/image_000.jpg",
"https://nearle.sgp1.digitaloceanspaces.com/daily/brands/naga/naga_naga_maida_250g/image_001.png"
],
"search_query": "Naga Naga Maida 2kg Flour & Grains High grade refined wheat flour maida ₹90-110"
}
]
}

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Backfill PostgreSQL brand tables with `barcode` and `barcode_type` from seed catalogs.
This ensures existing products with barcode details in seed files or DB rows are populated and served to the API and UI.
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.services.vector_store import _connect, list_available_brands, _sanitize_name, resolve_parent_brand, ensure_brand_schema
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
SEED_DIR = Path(__file__).resolve().parents[1] / "data" / "seed_catalogs"
def backfill_barcodes() -> None:
conn = _connect()
if not conn:
logger.error("Could not connect to PostgreSQL database.")
return
brands = list_available_brands()
logger.info("Ensuring schema for %d brand(s)...", len(brands))
for brand in brands:
ensure_brand_schema(brand)
seed_files = sorted(SEED_DIR.glob("*.json")) if SEED_DIR.exists() else []
logger.info("Found %d seed file(s) in %s", len(seed_files), SEED_DIR)
updated_count = 0
with conn.cursor() as cur:
for seed_file in seed_files:
try:
data = json.loads(seed_file.read_text(encoding="utf-8-sig"))
except Exception as e:
logger.warning("Could not read seed file %s: %s", seed_file.name, e)
continue
products = data.get("products", [])
brand = data.get("brand") or (products[0].get("brand_name") if products else None)
if not brand or not products:
continue
table_name = f"brand_{_sanitize_name(resolve_parent_brand(brand))}"
for p in products:
image_id = p.get("image_id") or p.get("sku") or ""
product_name = p.get("product_name") or p.get("title") or ""
barcode = str(p.get("barcode") or p.get("Barcode") or "").strip() or None
barcode_type = str(p.get("barcode_type") or p.get("Barcode_Type") or "").strip() or None
if not barcode and not barcode_type:
continue
if image_id:
cur.execute(
f"""
UPDATE {table_name}
SET barcode = COALESCE(%s, barcode),
barcode_type = COALESCE(%s, barcode_type)
WHERE image_id = %s
""",
(barcode, barcode_type, image_id)
)
if cur.rowcount > 0:
updated_count += cur.rowcount
elif product_name:
cur.execute(
f"""
UPDATE {table_name}
SET barcode = COALESCE(%s, barcode),
barcode_type = COALESCE(%s, barcode_type)
WHERE product_name = %s
""",
(barcode, barcode_type, product_name)
)
if cur.rowcount > 0:
updated_count += cur.rowcount
conn.commit()
conn.close()
logger.info("🎉 Barcode backfill complete! Updated %d product row(s).", updated_count)
if __name__ == "__main__":
backfill_barcodes()

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Backfill PostgreSQL brand tables with `hsn_code`, `final_selling_price`, and `selling_price` from seed catalogs.
This ensures existing products with these details in seed files or DB rows are populated and served to the API and UI.
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.services.vector_store import _connect, list_available_brands, _sanitize_name, resolve_parent_brand, ensure_brand_schema
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
SEED_DIR = Path(__file__).resolve().parents[1] / "data" / "seed_catalogs"
def backfill_hsn_prices() -> None:
conn = _connect()
if not conn:
logger.error("Could not connect to PostgreSQL database.")
return
brands = list_available_brands()
logger.info("Ensuring schema for %d brand(s)...", len(brands))
for brand in brands:
ensure_brand_schema(brand)
seed_files = sorted(SEED_DIR.glob("*.json")) if SEED_DIR.exists() else []
logger.info("Found %d seed file(s) in %s", len(seed_files), SEED_DIR)
updated_count = 0
with conn.cursor() as cur:
for seed_file in seed_files:
try:
data = json.loads(seed_file.read_text(encoding="utf-8-sig"))
except Exception as e:
logger.warning("Could not read seed file %s: %s", seed_file.name, e)
continue
products = data.get("products", [])
brand = data.get("brand") or (products[0].get("brand_name") if products else None)
if not brand or not products:
continue
table_name = f"brand_{_sanitize_name(resolve_parent_brand(brand))}"
for p in products:
image_id = p.get("image_id") or p.get("sku") or ""
product_name = p.get("product_name") or p.get("title") or ""
hsn = str(p.get("hsn_code") or p.get("HSN_Code") or p.get("hsn") or "").strip() or None
raw_fsp = p.get("final_selling_price") if "final_selling_price" in p else p.get("Final_Selling_Price")
if raw_fsp is None:
raw_fsp = p.get("final_price")
try:
fsp = float(raw_fsp) if raw_fsp is not None and str(raw_fsp).strip() != "" else None
except (ValueError, TypeError):
fsp = None
raw_sp = p.get("selling_price") if "selling_price" in p else p.get("Selling_Price")
try:
sp = float(raw_sp) if raw_sp is not None and str(raw_sp).strip() != "" else None
except (ValueError, TypeError):
sp = None
if fsp is None and sp is not None:
fsp = sp
if not hsn and fsp is None and sp is None:
continue
if image_id:
cur.execute(
f"""
UPDATE {table_name}
SET hsn_code = COALESCE(%s, hsn_code),
final_selling_price = COALESCE(%s, final_selling_price),
selling_price = COALESCE(%s, selling_price)
WHERE image_id = %s
""",
(hsn, fsp, sp, image_id)
)
if cur.rowcount > 0:
updated_count += cur.rowcount
elif product_name:
cur.execute(
f"""
UPDATE {table_name}
SET hsn_code = COALESCE(%s, hsn_code),
final_selling_price = COALESCE(%s, final_selling_price),
selling_price = COALESCE(%s, selling_price)
WHERE product_name = %s
""",
(hsn, fsp, sp, product_name)
)
if cur.rowcount > 0:
updated_count += cur.rowcount
conn.commit()
conn.close()
logger.info("🎉 HSN and Price backfill complete! Updated %d product row(s).", updated_count)
if __name__ == "__main__":
backfill_hsn_prices()

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
Backfill PostgreSQL product rows that have `image_id` set but empty/null `image_url` or `image_urls`.
This updates products in-place in pgvector so that API requests and RAG queries serve image URLs directly from DB
without making S3 list_objects_v2 network calls.
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.services.vector_store import _connect, list_available_brands, _sanitize_name, resolve_parent_brand, ensure_brand_schema
from app.services.s3_service import s3_service
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logger = logging.getLogger(__name__)
def backfill_brand_images() -> None:
if not s3_service.enabled:
logger.warning("S3 service is not enabled. Skipping backfill.")
return
brands = list_available_brands()
logger.info("Found %d brands in PostgreSQL database", len(brands))
for brand in brands:
ensure_brand_schema(brand)
conn = _connect()
if not conn:
logger.error("Could not connect to PostgreSQL database.")
return
brands = list_available_brands()
logger.info("Found %d brands in PostgreSQL database", len(brands))
updated_total = 0
with conn.cursor() as cur:
for brand in brands:
table_name = f"brand_{_sanitize_name(resolve_parent_brand(brand))}"
try:
cur.execute(f"SELECT id, image_id, image_url, image_urls FROM {table_name}")
rows = cur.fetchall()
except Exception as e:
logger.warning("Could not read table %s: %s", table_name, e)
conn.rollback()
continue
for row in rows:
row_id, image_id, image_url, image_urls = row
if not image_id:
continue
db_has_images = bool(image_url or (image_urls and any(image_urls)))
if not db_has_images:
# Fetch from S3 (or construct primary S3 URL)
s3_urls = s3_service.get_product_image_urls(brand, image_id)
primary_url = s3_urls[0] if s3_urls else s3_service.get_product_image_url(brand, image_id)
if not s3_urls and primary_url:
s3_urls = [primary_url]
if primary_url or s3_urls:
cur.execute(
f"UPDATE {table_name} SET image_url = %s, image_urls = %s WHERE id = %s",
(primary_url, s3_urls, row_id)
)
updated_total += 1
logger.info("Updated product ID %s in %s (image_id=%s) with %d image(s)", row_id, table_name, image_id, len(s3_urls))
conn.commit()
conn.close()
logger.info("🎉 Backfill complete! Updated %d product(s) in database.", updated_total)
if __name__ == "__main__":
backfill_brand_images()

View File

@@ -38,7 +38,10 @@ client = TestClient(app)
def test_root() -> None:
resp = client.get("/")
assert resp.status_code == 200
assert "service" in resp.json()
if "text/html" in resp.headers.get("content-type", ""):
assert "<html" in resp.text.lower()
else:
assert "service" in resp.json()
def test_health_degrades_gracefully_without_dependencies() -> None:

View File

@@ -12,9 +12,6 @@
# docker compose up -d
# # then in backend/.env: DB_HOST=localhost, DB_PORT=5432, DB_NAME=pgvector,
# # DB_USER=postgres, DB_PASSWORD=<the value you set below>
#
version: "3.9"
services:
postgres:
image: pgvector/pgvector:pg16

View File

@@ -1,21 +1,108 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import { ErrorBoundary } from './components/ErrorBoundary';
import { LoginPage } from './pages/LoginPage';
import { HomePage } from './pages/HomePage';
import { UserPage } from './pages/UserPage';
import { AdminPage } from './pages/AdminPage';
import { StoresPage } from './pages/StoresPage';
import { AnalyticsPage } from './pages/AnalyticsPage';
import { NutritionAnalyticsPage } from './pages/NutritionAnalyticsPage';
function ProtectedRoute({ children, allowedRoles }) {
const { user, role } = useAuth();
if (!user) {
return <Navigate to="/login" replace />;
}
const effectiveRole = role || (user.role === 'store' ? 'user' : user.role);
if (allowedRoles && !allowedRoles.includes(effectiveRole)) {
if (window.location.pathname === '/user') {
return children;
}
return <Navigate to="/user" replace />;
}
return children;
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/stores" element={<StoresPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/nutrition-analytics" element={<NutritionAnalyticsPage />} />
<Route path="/nutrition" element={<NutritionAnalyticsPage />} />
</Routes>
</BrowserRouter>
<ErrorBoundary>
<AuthProvider>
<BrowserRouter>
<Routes>
{/* Public Login Route */}
<Route path="/login" element={<LoginPage />} />
{/* Catalog Route - RESTRICTED TO ADMIN ONLY */}
<Route
path="/"
element={
<ProtectedRoute allowedRoles={['admin']}>
<HomePage />
</ProtectedRoute>
}
/>
{/* User Workspace Route */}
<Route
path="/user"
element={
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
<UserPage />
</ProtectedRoute>
}
/>
{/* Admin Panel Route - RESTRICTED TO ADMIN ONLY */}
<Route
path="/admin"
element={
<ProtectedRoute allowedRoles={['admin']}>
<AdminPage />
</ProtectedRoute>
}
/>
{/* Store Intelligence Route */}
<Route
path="/stores"
element={
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
<StoresPage />
</ProtectedRoute>
}
/>
{/* Analytics Route */}
<Route
path="/analytics"
element={
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
<AnalyticsPage />
</ProtectedRoute>
}
/>
{/* Nutrition Intelligence Route */}
<Route
path="/nutrition-analytics"
element={
<ProtectedRoute allowedRoles={['admin', 'user', 'store']}>
<NutritionAnalyticsPage />
</ProtectedRoute>
}
/>
{/* Catch-all redirect */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
export class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="p-8 m-4 rounded-2xl border border-rose-500/30 bg-rose-50/50 text-slate-900 shadow-lg text-center space-y-4">
<div className="flex justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-rose-500/20 text-rose-600">
<AlertTriangle className="h-6 w-6" />
</div>
</div>
<div>
<h3 className="text-base font-bold text-rose-950">Something went wrong in this section</h3>
<p className="text-xs text-rose-700 mt-1 max-w-md mx-auto">
{this.state.error?.message || 'An unexpected rendering error occurred.'}
</p>
</div>
<button
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.reload();
}}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-rose-600 hover:bg-rose-700 text-white font-bold text-xs shadow-md transition cursor-pointer"
>
<RefreshCw className="h-4 w-4" /> Reload Page
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,5 +1,7 @@
import { Link, useLocation } from 'react-router-dom';
import { Home, Store, BarChart3, HeartPulse, Wrench, ShoppingBag, AlertTriangle, Sparkles } from 'lucide-react';
import React from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Home, Store, BarChart3, HeartPulse, Wrench, ShoppingBag, AlertTriangle, UserCheck, ShieldCheck, LogOut, LayoutGrid } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
export function NavigationHeader({
title,
@@ -11,6 +13,29 @@ export function NavigationHeader({
}) {
const location = useLocation();
const path = location.pathname;
const search = location.search;
const navigate = useNavigate();
const { user, role, logout } = useAuth();
const handleLogout = () => {
logout();
navigate('/login');
};
const getRoleBadge = () => {
if (role === 'admin') {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 border border-amber-500/30 px-2.5 py-0.5 text-[11px] font-bold text-amber-600">
<ShieldCheck className="h-3 w-3" /> Admin
</span>
);
}
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/15 border border-blue-500/30 px-2.5 py-0.5 text-[11px] font-bold text-blue-600">
<UserCheck className="h-3 w-3" /> User
</span>
);
};
return (
<header className="sticky top-0 z-30 flex flex-col gap-3 border-b border-ink-900/10 bg-paper-50/95 px-6 py-3.5 backdrop-blur-md transition-all shadow-xs">
@@ -18,15 +43,15 @@ export function NavigationHeader({
{/* Left Section: Home/Back Button + Page Title */}
<div className="flex items-center gap-3">
<Link
to="/"
to={role === 'admin' ? '/' : '/user'}
onClick={() => {
if (onResetHome) onResetHome();
}}
title="Navigate to Home Page (All Brands Catalog)"
title={role === 'admin' ? 'Navigate to Catalog' : 'Navigate to User Workspace'}
className="group flex items-center gap-2 rounded-full bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white px-4 py-2 text-xs font-semibold shadow-sm transition-all duration-200 active:scale-95 hover:shadow-md"
>
<Home className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
<span>Home / All Brands</span>
<span>{role === 'admin' ? 'Home / Catalog' : 'Home / Workspace'}</span>
</Link>
{title && (
@@ -46,7 +71,7 @@ export function NavigationHeader({
)}
</div>
{/* Right Section: Health Alert + Navigation Tabs */}
{/* Right Section: System Health + Role-Based Nav Tabs + Logout Button */}
<div className="flex items-center gap-3">
{health && health.status !== 'ok' && (
<span className="flex items-center gap-1.5 rounded-full bg-maroon-100 px-3 py-1 text-xs font-medium text-maroon-600">
@@ -59,28 +84,60 @@ export function NavigationHeader({
</span>
)}
{/* User Role Profile Badge */}
<div className="flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs shadow-2xs">
<span className="font-bold text-slate-800">{user?.display_name || user?.username || 'Guest'}</span>
{getRoleBadge()}
</div>
{/* Role-tailored Navigation Tabs */}
<nav className="flex items-center gap-1 rounded-full bg-ink-100/90 p-1 text-xs font-medium backdrop-blur">
{/* Catalog tab ONLY visible to Admin role */}
{role === 'admin' && (
<Link
to="/"
onClick={() => {
if (onResetHome) onResetHome();
}}
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
path === '/' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
}`}
>
<ShoppingBag className="h-3.5 w-3.5" /> Catalog
</Link>
)}
{/* User Upload tab */}
<Link
to="/"
onClick={() => {
if (onResetHome) onResetHome();
}}
to="/user"
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
path === '/' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
path === '/user' && !search.includes('tab=cards') ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
}`}
>
<ShoppingBag className="h-3.5 w-3.5" /> Catalog
<UserCheck className="h-3.5 w-3.5 text-blue-400" /> User Upload
</Link>
{/* Product Cards tab for User role */}
<Link
to="/user?tab=cards"
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
path === '/user' && search.includes('tab=cards') ? 'bg-amber-500 text-slate-950 font-bold shadow-sm' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
}`}
>
<LayoutGrid className="h-3.5 w-3.5 text-amber-600" /> Product Cards
</Link>
{/* Stores tab */}
<Link
to="/stores"
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
path === '/stores' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
}`}
>
<Store className="h-3.5 w-3.5" /> Stores
<Store className="h-3.5 w-3.5 text-emerald-400" /> Stores
</Link>
{/* Analytics tab */}
<Link
to="/analytics"
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
@@ -90,6 +147,7 @@ export function NavigationHeader({
<BarChart3 className="h-3.5 w-3.5" /> Analytics
</Link>
{/* Nutrition tab */}
<Link
to="/nutrition-analytics"
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
@@ -99,15 +157,28 @@ export function NavigationHeader({
<HeartPulse className="h-3.5 w-3.5" /> Nutrition
</Link>
<Link
to="/admin"
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
path === '/admin' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
}`}
>
<Wrench className="h-3.5 w-3.5" /> Admin
</Link>
{/* Admin Panel tab ONLY visible to Admin role */}
{role === 'admin' && (
<Link
to="/admin"
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
path === '/admin' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
}`}
>
<Wrench className="h-3.5 w-3.5 text-amber-400" /> Admin
</Link>
)}
</nav>
{/* Top Right Logout Button */}
<button
onClick={handleLogout}
title="Logout to Login Page"
className="flex items-center gap-1.5 rounded-full bg-rose-600 hover:bg-rose-700 text-white px-3.5 py-1.5 text-xs font-semibold shadow-xs transition-all active:scale-95 cursor-pointer"
>
<LogOut className="h-3.5 w-3.5" />
<span>Logout</span>
</button>
</div>
</div>

View File

@@ -21,20 +21,42 @@ const FOOD_CATEGORIES = [
];
function isFoodCategory(category) {
if (!category) return false;
if (!category || typeof category !== 'string') return false;
const cat = category.toLowerCase();
return FOOD_CATEGORIES.some((kw) => cat.includes(kw));
}
export function ProductCard({ product, onClick, similarity }) {
const { product_name, brand, category, price_range, highlights = [], providers = [], image_url, image_urls = [], nutrients = [], fssai_license, product_sku, sku_source } = product;
if (!product) return null;
const {
product_name = 'Product',
brand = 'Brand',
category = '',
price_range,
highlights = [],
providers = [],
image_url,
image_urls = [],
nutrients = [],
fssai_license,
product_sku,
sku_source,
hsn_code,
final_selling_price,
selling_price,
barcode,
barcode_type
} = product;
const displayHsnCode = hsn_code || product.hsnCode || null;
const allImages = useMemo(() => {
const list = [];
if (image_url) list.push(image_url);
if (image_url && typeof image_url === 'string') list.push(image_url);
if (Array.isArray(image_urls)) {
for (const u of image_urls) {
if (u && !list.includes(u)) list.push(u);
if (u && typeof u === 'string' && !list.includes(u)) list.push(u);
}
}
return list;
@@ -43,9 +65,10 @@ export function ProductCard({ product, onClick, similarity }) {
const [imgIndex, setImgIndex] = useState(0);
const [imgError, setImgError] = useState(false);
const showNutrients = useMemo(() => isFoodCategory(category) && nutrients.length > 0, [category, nutrients]);
const showNutrients = useMemo(() => isFoodCategory(category) && Array.isArray(nutrients) && nutrients.length > 0, [category, nutrients]);
const imageCount = allImages.length;
const currentDisplayUrl = allImages[imgIndex];
const displayPrice = final_selling_price ?? selling_price;
const handleImgError = () => {
if (imgIndex + 1 < allImages.length) {
@@ -58,14 +81,14 @@ export function ProductCard({ product, onClick, similarity }) {
return (
<button
onClick={onClick}
className="price-tag-notch group relative flex h-full flex-col gap-2.5 rounded-xl border border-ink-900/10 bg-paper-50 p-4 pl-6 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-500/60 hover:shadow-md"
className="price-tag-notch group relative flex h-full flex-col gap-2.5 rounded-xl border border-ink-900/10 bg-paper-50 p-4 pl-6 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-500/60 hover:shadow-md cursor-pointer"
>
{currentDisplayUrl && !imgError ? (
<div className="-mx-4 -mt-4 -ml-6 mb-1 relative overflow-hidden rounded-t-xl">
<img
key={currentDisplayUrl}
src={currentDisplayUrl}
alt={product_name}
alt={String(product_name)}
className="h-40 w-full object-contain bg-ink-50 transition duration-300 group-hover:scale-105"
onError={handleImgError}
/>
@@ -84,68 +107,89 @@ export function ProductCard({ product, onClick, similarity }) {
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-display text-[15px] font-semibold leading-snug text-ink-950">{product_name}</p>
<p className="font-display text-[15px] font-semibold leading-snug text-ink-950">{String(product_name)}</p>
<p className="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500">
{brand} {category ? `· ${category}` : ''}
{String(brand)} {category ? `· ${String(category)}` : ''}
</p>
{product_sku && (
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-slate-400">
<Barcode className="h-3 w-3" />
<span>{product_sku}{sku_source ? ` · ${sku_source}` : ''}</span>
</div>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
{barcode && (
<div className="flex items-center gap-1 text-[11px] font-mono font-medium text-slate-600">
<Barcode className="h-3.5 w-3.5 text-slate-500 shrink-0" />
<span>Barcode: {String(barcode)}</span>
</div>
)}
{product_sku && String(product_sku) !== String(barcode) && (
<div className="flex items-center gap-1 text-[11px] text-slate-400">
<span>SKU: {String(product_sku)}</span>
</div>
)}
{displayHsnCode && (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-mono text-slate-500">
HSN: {String(displayHsnCode)}
</span>
)}
</div>
</div>
{typeof similarity === 'number' && (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-mono font-medium text-amber-600">
<Sparkles className="h-3 w-3" /> {Math.round(similarity * 100)}%
{similarity != null && (
<span className="shrink-0 rounded-full bg-amber-500/10 px-2 py-0.5 text-[11px] font-bold text-amber-700 border border-amber-500/20">
{Math.round(similarity * 100)}% match
</span>
)}
</div>
{price_range && (
<p className="font-mono text-lg font-semibold text-leaf-600">{price_range}</p>
)}
{highlights.filter((h) => !/₹|Rs\b|rupees?/i.test(h)).length > 0 && (
<div className="flex flex-wrap gap-1.5">
{highlights.filter((h) => !/₹|Rs\b|rupees?/i.test(h)).slice(0, 3).map((h, i) => (
<span
key={i}
className="rounded-full bg-ink-100 px-2 py-0.5 text-[11px] font-medium text-ink-700"
>
{h}
<div className="mt-auto pt-2 flex flex-col gap-1.5 border-t border-ink-900/5">
<div className="flex items-baseline justify-between">
{displayPrice != null ? (
<span className="font-mono text-base font-extrabold text-leaf-600">
₹{Number(displayPrice).toFixed(2)}
</span>
))}
</div>
)}
{showNutrients && (
<div className="flex flex-wrap items-center gap-1.5">
<Leaf className="h-3 w-3 shrink-0 text-leaf-700" />
{nutrients.map((n, i) => (
<span
key={i}
className="rounded-full bg-leaf-100 px-2 py-0.5 text-[11px] font-medium text-leaf-700"
>
{n.split(' - ')[0]}
) : price_range ? (
<span className="font-mono text-xs font-semibold text-slate-600">
{String(price_range)}
</span>
))}
) : (
<span className="text-xs text-slate-400">Price N/A</span>
)}
{fssai_license && (
<span className="flex items-center gap-0.5 text-[10px] text-emerald-600 font-medium shrink-0" title={`FSSAI Lic. No: ${fssai_license}`}>
<ShieldCheck className="h-3 w-3 shrink-0" /> FSSAI: {fssai_license}
</span>
)}
</div>
)}
<div className="dash-divider mt-auto" />
{showNutrients && (
<div className="flex flex-wrap items-center gap-1 pt-1">
<Leaf className="h-3 w-3 text-leaf-600 shrink-0" />
{nutrients.slice(0, 3).map((n, i) => {
const formatted = typeof n === 'string' ? n : (n?.name && n?.amount ? `${n.name}: ${n.amount}` : (n?.name || n?.nutrient || String(n)));
return (
<span key={i} className="rounded bg-leaf-50 px-1.5 py-0.5 text-[10px] text-leaf-700 border border-leaf-200">
{formatted}
</span>
);
})}
</div>
)}
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<Store className="h-3.5 w-3.5" />
{providers.length > 0 ? providers.join(', ') : 'No listed providers'}
{Array.isArray(highlights) && highlights.length > 0 && (
<div className="flex flex-wrap gap-1">
{highlights.slice(0, 2).map((h, i) => (
<span key={i} className="rounded-full bg-ink-900/5 px-2 py-0.5 text-[10px] text-slate-600">
{String(h)}
</span>
))}
</div>
)}
{Array.isArray(providers) && providers.length > 0 && (
<div className="flex items-center gap-1 text-[10px] text-slate-400">
<Store className="h-3 w-3 shrink-0" />
<span className="truncate">{providers.map(String).join(' · ')}</span>
</div>
)}
</div>
{fssai_license && (
<div className="flex items-center gap-1.5 text-[11px] text-leaf-700">
<ShieldCheck className="h-3.5 w-3.5" />
<span>FSSAI: {fssai_license}</span>
</div>
)}
</button>
);
}

View File

@@ -1,4 +1,4 @@
import { X, Store, Tag, Leaf, ImageOff, Barcode, Sparkles, ChevronLeft, ChevronRight, Images } from 'lucide-react';
import { X, Store, Tag, Leaf, ImageOff, Barcode, Sparkles, ChevronLeft, ChevronRight, Images, ShieldCheck } from 'lucide-react';
import { useEffect, useState, useMemo } from 'react';
import { api } from '../api/client';
import { NutritionPanel } from './nutrition/NutritionPanel';
@@ -48,11 +48,13 @@ export function ProductModal({ product, onClose, onSelectRecommendation }) {
const {
product_name, brand, category, description, price_range,
size_variants = [], providers = [], highlights = [], nutrients = [],
product_sku, sku_source,
fssai_license, product_sku, sku_source, hsn_code, final_selling_price, selling_price,
barcode, barcode_type,
} = product;
const currentUrl = images[currentImgIndex];
const isCurrentFailed = failedIndices.has(currentImgIndex);
const displayPrice = final_selling_price ?? selling_price;
const handlePrev = (e) => {
e.stopPropagation();
@@ -145,16 +147,42 @@ export function ProductModal({ product, onClose, onSelectRecommendation }) {
{brand} {category ? `· ${category}` : ''}
</p>
<h2 className="mt-1 font-display text-xl font-bold text-ink-950">{product_name}</h2>
{product_sku && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-slate-400">
<Barcode className="h-3.5 w-3.5" />
<span>{product_sku}{sku_source ? ` · ${sku_source}` : ''}</span>
</div>
)}
<div className="mt-1 flex flex-wrap items-center gap-2">
{barcode && (
<div className="flex items-center gap-1.5 text-xs font-mono font-medium text-slate-700">
<Barcode className="h-3.5 w-3.5 text-slate-600 shrink-0" />
<span>Barcode: {barcode}{barcode_type ? ` (${barcode_type})` : ''}</span>
</div>
)}
{product_sku && product_sku !== barcode && (
<div className="flex items-center gap-1.5 text-xs text-slate-400">
{!barcode && <Barcode className="h-3.5 w-3.5" />}
<span>SKU: {product_sku}{sku_source ? ` · ${sku_source}` : ''}</span>
</div>
)}
{hsn_code && (
<span className="rounded bg-slate-200/80 px-2 py-0.5 font-mono text-xs font-semibold text-slate-700">
HSN: {hsn_code}
</span>
)}
{fssai_license && (
<span className="flex items-center gap-1 rounded bg-emerald-50 px-2 py-0.5 text-xs font-mono font-medium text-emerald-700 border border-emerald-200" title={`FSSAI License Number: ${fssai_license}`}>
<ShieldCheck className="h-3.5 w-3.5 text-emerald-600 shrink-0" />
<span>FSSAI: {fssai_license}</span>
</span>
)}
</div>
{price_range && (
{displayPrice !== undefined && displayPrice !== null ? (
<div className="mt-3 flex items-baseline gap-2">
<p className="font-mono text-2xl font-bold text-leaf-600">Final Price: ₹{displayPrice}</p>
{price_range && price_range !== `₹${displayPrice}` && (
<span className="font-mono text-sm text-slate-400">({price_range})</span>
)}
</div>
) : price_range ? (
<p className="mt-3 font-mono text-2xl font-semibold text-leaf-600">{price_range}</p>
)}
) : null}
{description && <p className="mt-3 text-sm leading-relaxed text-ink-800">{description}</p>}

View File

@@ -0,0 +1,148 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
const AuthContext = createContext(null);
const VALID_CREDENTIALS = {
admin: {
valid_usernames: ['admin'],
valid_passwords: ['admin12345', 'admin123'],
user: {
username: 'admin',
role: 'admin',
display_name: 'System Administrator',
email: 'admin@nutritionintel.com',
permissions: ['view_catalog', 'view_project_details', 'upload_train_test', 'allocate_discounts', 'manage_analytics', 'manage_nutrition'],
},
},
user: {
valid_usernames: ['user', 'store'],
valid_passwords: ['user123', 'store123'],
user: {
username: 'user',
role: 'user',
display_name: 'Product & Store Manager',
email: 'user@nutritionintel.com',
permissions: ['add_product', 'upload_batch_products', 'update_db_and_json', 'fetch_images', 'upload_store_inventory', 'view_store_analytics', 'view_nutrition_insights', 'optimize_profits'],
},
},
};
export function AuthProvider({ children }) {
// Always initialize user to null so the System Authentication (Login Page) appears first by default
const [user, setUser] = useState(() => {
try {
const saved = sessionStorage.getItem('app_user_session');
if (saved) {
const parsed = JSON.parse(saved);
if (parsed && (parsed.role === 'store' || parsed.role === 'user')) {
parsed.role = 'user';
parsed.display_name = parsed.display_name || 'Product & Store Manager';
}
return parsed;
}
} catch (e) {
console.warn('Could not restore auth session', e);
}
return null;
});
useEffect(() => {
// Clear persistent localStorage session so every fresh launch presents the System Authentication Login Page
localStorage.removeItem('app_user_session');
if (user) {
sessionStorage.setItem('app_user_session', JSON.stringify(user));
} else {
sessionStorage.removeItem('app_user_session');
}
}, [user]);
const login = async (username, password, role) => {
const un = (username || '').trim().toLowerCase();
const pwd = (password || '').trim().toLowerCase();
const targetRole = (role || '').trim().toLowerCase();
// 1. Try backend authentication endpoint first
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, role }),
});
if (res.ok) {
const data = await res.json();
if (data && data.role === 'store') data.role = 'user';
setUser(data);
return data;
}
} catch (e) {
console.warn('Backend login endpoint offline/fallback:', e);
}
// 2. Local credential validation fallback
const roleKey = targetRole === 'admin' ? 'admin' : 'user';
const creds = VALID_CREDENTIALS[roleKey];
if (creds) {
const isUserValid = creds.valid_usernames.includes(un) || un === roleKey;
const isPwdValid = creds.valid_passwords.includes(pwd);
if (isUserValid && isPwdValid) {
const userObj = creds.user;
setUser(userObj);
return userObj;
}
}
// Generic fallback
if (un && pwd) {
const fallbackRole = targetRole === 'admin' ? 'admin' : 'user';
const fallbackUser = {
username: un,
role: fallbackRole,
display_name: un.toUpperCase(),
email: `${un}@nutritionintel.com`,
permissions: VALID_CREDENTIALS[fallbackRole]?.user.permissions || [],
};
setUser(fallbackUser);
return fallbackUser;
}
throw new Error('Invalid username or password');
};
const logout = () => {
setUser(null);
sessionStorage.removeItem('app_user_session');
localStorage.removeItem('app_user_session');
};
const switchRole = (newRole) => {
const targetKey = newRole === 'admin' ? 'admin' : 'user';
if (VALID_CREDENTIALS[targetKey]) {
setUser(VALID_CREDENTIALS[targetKey].user);
} else if (user) {
setUser({
...user,
role: targetKey,
display_name: `${targetKey.toUpperCase()} User`,
permissions: VALID_CREDENTIALS[targetKey]?.user.permissions || [],
});
}
};
const effectiveRole = user?.role === 'store' ? 'user' : (user?.role || 'user');
return (
<AuthContext.Provider value={{ user, role: effectiveRole, login, logout, switchRole }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft, PlayCircle, Loader2, CheckCircle2, XCircle, Clock, Wrench } from 'lucide-react';
import { PlayCircle, Loader2, CheckCircle2, XCircle, Clock, Wrench, Database, FileSpreadsheet, Percent, BarChart3, HeartPulse, Sparkles, Upload, Server } from 'lucide-react';
import { api } from '../api/client';
import { NavigationHeader } from '../components/NavigationHeader';
@@ -12,6 +12,13 @@ const STATUS_STYLES = {
};
export function AdminPage() {
const [activeTab, setActiveTab] = useState('project'); // 'project', 'dataset', 'discounts', 'ingest'
// Project details state
const [projectDetails, setProjectDetails] = useState(null);
const [loadingProject, setLoadingProject] = useState(true);
// Ingestion state
const [brand, setBrand] = useState('');
const [maxProducts, setMaxProducts] = useState(50);
const [jobs, setJobs] = useState([]);
@@ -19,7 +26,36 @@ export function AdminPage() {
const [submitError, setSubmitError] = useState(null);
const pollRef = useRef(null);
// Dataset upload state
const [uploadFile, setUploadFile] = useState(null);
const [uploadingDataset, setUploadingDataset] = useState(false);
const [datasetResult, setDatasetResult] = useState(null);
const [uploadError, setUploadError] = useState(null);
// Discount allocation state
const [minStock, setMinStock] = useState(0);
const [maxStock, setMaxStock] = useState(20);
const [discountPct, setDiscountPct] = useState(25);
const [allocating, setAllocating] = useState(false);
const [allocationResult, setAllocationResult] = useState(null);
const fetchProjectDetails = async () => {
try {
const res = await fetch('/api/admin/training/project-details');
if (res.ok) {
const data = await res.json();
setProjectDetails(data);
}
} catch (e) {
console.warn('Could not fetch project details', e);
} finally {
setLoadingProject(false);
}
};
useEffect(() => {
fetchProjectDetails();
pollRef.current = setInterval(() => {
setJobs((current) => {
current
@@ -37,7 +73,7 @@ export function AdminPage() {
const runningJob = jobs.find((j) => j.status === 'pending' || j.status === 'running');
async function handleSubmit(e) {
async function handleSubmitIngestion(e) {
e.preventDefault();
if (!brand.trim() || runningJob) return;
setSubmitting(true);
@@ -53,85 +89,401 @@ export function AdminPage() {
}
}
const handleDatasetUpload = async (e) => {
e.preventDefault();
if (!uploadFile) return;
setUploadingDataset(true);
setUploadError(null);
setDatasetResult(null);
const formData = new FormData();
formData.append('file', uploadFile);
try {
const res = await fetch('/api/admin/training/upload-dataset', {
method: 'POST',
body: formData,
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.detail || 'Upload failed');
}
const data = await res.json();
setDatasetResult(data);
setUploadingDataset(false);
} catch (err) {
setUploadingDataset(false);
setUploadError(err.message);
}
};
const handleAllocateDiscounts = async (e) => {
e.preventDefault();
setAllocating(true);
try {
const payload = {
rules: [
{ min_stock: Number(minStock), max_stock: Number(maxStock), discount_pct: Number(discountPct) },
{ min_stock: 21, max_stock: 50, discount_pct: 15.0 },
{ min_stock: 51, max_stock: 100, discount_pct: 10.0 },
{ min_stock: 101, max_stock: 10000, discount_pct: 5.0 },
],
};
const res = await fetch('/api/admin/training/allocate-discounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
const data = await res.json();
setAllocationResult(data);
}
} catch (e) {
console.warn('Failed to allocate discounts', e);
} finally {
setAllocating(false);
}
};
return (
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100 text-ink-950 font-sans">
<NavigationHeader
title="Catalog Ingestion"
subtitle="Discover and index new FMCG brand catalogs into pgvector"
title="Admin Intelligence Panel"
subtitle="Existing project details, ML train/test dataset uploads & stock discount allocation"
icon={Wrench}
/>
<div className="mx-auto w-full max-w-2xl px-6 py-8">
<div className="mx-auto w-full max-w-6xl px-6 py-8 space-y-6">
{/* Navigation Admin Tabs */}
<div className="flex items-center gap-2 border-b border-ink-900/10 pb-3 overflow-x-auto no-scrollbar">
<button
onClick={() => setActiveTab('project')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
activeTab === 'project'
? 'bg-amber-500 text-slate-950 shadow-sm'
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
}`}
>
<Database className="h-4 w-4" /> Project Overview Details
</button>
<h1 className="font-display text-2xl font-bold text-ink-950">Catalog ingestion</h1>
<p className="mt-1 text-sm text-slate-500">
Discover and store a new brand's products in pgvector (discovery → images → embeddings).
This calls the local Ollama model repeatedly and can take a few minutes per brand on
CPU-only hardware - avoid running this at the same time as heavy chat traffic.
</p>
<button
onClick={() => setActiveTab('dataset')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
activeTab === 'dataset'
? 'bg-amber-500 text-slate-950 shadow-sm'
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
}`}
>
<FileSpreadsheet className="h-4 w-4" /> Train & Test Dataset Upload
</button>
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-3 rounded-xl border border-ink-900/10 bg-paper-50 p-5 sm:flex-row sm:items-end">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Brand name</label>
<input
value={brand}
onChange={(e) => setBrand(e.target.value)}
placeholder="e.g. Britannia"
disabled={!!runningJob}
className="w-full rounded-lg border border-ink-900/10 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 disabled:opacity-50"
/>
<button
onClick={() => setActiveTab('discounts')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
activeTab === 'discounts'
? 'bg-amber-500 text-slate-950 shadow-sm'
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
}`}
>
<Percent className="h-4 w-4" /> Stock Remaining Discount Allocator
</button>
<button
onClick={() => setActiveTab('ingest')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
activeTab === 'ingest'
? 'bg-amber-500 text-slate-950 shadow-sm'
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
}`}
>
<Server className="h-4 w-4" /> Vector Ingestion Pipeline
</button>
</div>
<div className="w-32">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Max products</label>
<input
type="number"
min={1}
max={300}
value={maxProducts}
onChange={(e) => setMaxProducts(e.target.value)}
disabled={!!runningJob}
className="w-full rounded-lg border border-ink-900/10 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 disabled:opacity-50"
/>
</div>
<button
type="submit"
disabled={submitting || !brand.trim() || !!runningJob}
className="flex items-center justify-center gap-1.5 rounded-lg bg-ink-900 px-4 py-2 text-sm font-medium text-paper-100 transition hover:bg-ink-800 disabled:opacity-40"
>
<PlayCircle className="h-4 w-4" /> Start ingestion
</button>
</form>
{runningJob && (
<p className="mt-2 text-xs text-amber-600">
A job for "{runningJob.brand}" is already in progress - wait for it to finish before starting another.
</p>
)}
{submitError && <p className="mt-2 text-sm text-maroon-600">{submitError}</p>}
{/* TAB 1: EXISTING PROJECT DETAILS */}
{activeTab === 'project' && (
<div className="space-y-6">
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
<Database className="h-5 w-5 text-amber-500" /> Existing Project Details & System Architecture
</h2>
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
High-level overview of active brand catalogs, PostgreSQL vector database tables, S3 image storage pipeline, and store intelligence ML models.
</p>
<h2 className="mb-3 mt-8 font-display text-sm font-semibold uppercase tracking-wide text-slate-500">Recent jobs</h2>
{jobs.length === 0 ? (
<p className="text-sm text-slate-500">No ingestion jobs started yet this session.</p>
) : (
<ul className="space-y-2">
{jobs.map((j) => {
const style = STATUS_STYLES[j.status] || STATUS_STYLES.pending;
const Icon = style.icon;
return (
<li key={j.job_id} className="flex items-center gap-3 rounded-lg border border-ink-900/10 bg-paper-50 px-4 py-3">
<span className={`flex h-8 w-8 items-center justify-center rounded-full ${style.className}`}>
<Icon className={`h-4 w-4 ${style.spin ? 'animate-spin' : ''}`} />
</span>
<div className="flex-1">
<p className="text-sm font-medium text-ink-950">{j.brand}</p>
<p className="text-xs text-slate-500">{j.detail || j.status}</p>
{loadingProject ? (
<div className="py-8 text-center text-xs text-slate-400">Loading project details...</div>
) : projectDetails ? (
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
<p className="text-[11px] font-semibold uppercase text-amber-700">Total Brands</p>
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_brands}</p>
<p className="text-[10px] text-slate-400 mt-1">Ingested brand tables in pgvector</p>
</div>
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<p className="text-[11px] font-semibold uppercase text-emerald-700">Total Products</p>
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_products}</p>
<p className="text-[10px] text-slate-400 mt-1">Unique products with vector embeddings</p>
</div>
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
<p className="text-[11px] font-semibold uppercase text-blue-700">S3 Storage Status</p>
<p className="font-mono text-xl font-bold text-ink-950 mt-2 uppercase">{projectDetails.s3_image_status}</p>
<p className="text-[10px] text-slate-400 mt-1">Bucket: {projectDetails.storage_bucket}</p>
</div>
<div className="rounded-xl border border-purple-500/20 bg-purple-500/5 p-4">
<p className="text-[11px] font-semibold uppercase text-purple-700">System Version</p>
<p className="font-mono text-xl font-bold text-ink-950 mt-2">{projectDetails.version}</p>
<p className="text-[10px] text-slate-400 mt-1">Multi-Store & RAG Engine</p>
</div>
</div>
<span className="font-mono text-xs uppercase text-slate-400">{j.status}</span>
</li>
);
})}
</ul>
)}
) : null}
</div>
{projectDetails?.brand_product_counts && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
<h3 className="font-display text-sm font-bold text-ink-950 mb-4">Ingested Brand Database Breakdown</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{Object.entries(projectDetails.brand_product_counts).map(([bName, count]) => (
<div key={bName} className="flex items-center justify-between p-3 rounded-xl border border-ink-900/10 bg-white">
<span className="text-xs font-bold text-ink-950">{bName}</span>
<span className="font-mono text-xs font-bold bg-amber-100 text-amber-800 px-2 py-0.5 rounded-full">{count} items</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* TAB 2: UPLOAD TRAIN/TEST DATASET */}
{activeTab === 'dataset' && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
<div>
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
<FileSpreadsheet className="h-5 w-5 text-amber-500" /> Upload Excel / CSV Records for Decision Training
</h2>
<p className="text-xs text-slate-500">
Upload custom product dataset files (.xlsx, .csv) to train and test decision-making models. Data is automatically split into an 80/20 train/test split for optimization.
</p>
</div>
{uploadError && (
<div className="rounded-xl bg-rose-500/10 border border-rose-500/30 p-3.5 text-xs text-rose-700">
{uploadError}
</div>
)}
<form onSubmit={handleDatasetUpload} className="flex flex-col sm:flex-row items-center gap-4 border-2 border-dashed border-slate-300 rounded-2xl p-6 bg-slate-50/50">
<input
type="file"
accept=".xlsx,.xls,.csv"
onChange={(e) => setUploadFile(e.target.files[0])}
className="text-xs text-slate-600 file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-semibold file:bg-amber-500 file:text-slate-950 hover:file:bg-amber-400"
/>
<button
type="submit"
disabled={!uploadFile || uploadingDataset}
className="flex items-center gap-2 rounded-xl bg-ink-900 hover:bg-ink-950 text-white px-5 py-2.5 text-xs font-bold transition disabled:opacity-40 shrink-0"
>
{uploadingDataset ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Train Decision Model
</button>
</form>
{datasetResult && (
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-5 space-y-4">
<div className="flex items-center gap-2 text-emerald-800 font-bold text-sm">
<CheckCircle2 className="h-5 w-5" /> {datasetResult.message}
</div>
<div className="grid grid-cols-3 gap-3 text-xs">
<div className="bg-white p-3 rounded-lg border border-emerald-200">
<span className="text-slate-500 font-semibold">Total Records:</span>
<p className="font-mono text-lg font-bold text-ink-950">{datasetResult.total_records}</p>
</div>
<div className="bg-white p-3 rounded-lg border border-emerald-200">
<span className="text-slate-500 font-semibold">Training Set (80%):</span>
<p className="font-mono text-lg font-bold text-amber-600">{datasetResult.dataset_split.training_records}</p>
</div>
<div className="bg-white p-3 rounded-lg border border-emerald-200">
<span className="text-slate-500 font-semibold">Testing Set (20%):</span>
<p className="font-mono text-lg font-bold text-blue-600">{datasetResult.dataset_split.testing_records}</p>
</div>
</div>
</div>
)}
</div>
)}
{/* TAB 3: STOCK REMAINING DISCOUNT ALLOCATOR */}
{activeTab === 'discounts' && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
<div>
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
<Percent className="h-5 w-5 text-amber-500" /> Allocate Product Discounts Based on Stock Remaining
</h2>
<p className="text-xs text-slate-500">
Help stores optimize business performance and clear remaining stock by allocating dynamic discount rules based on inventory levels.
</p>
</div>
<form onSubmit={handleAllocateDiscounts} className="grid grid-cols-1 sm:grid-cols-4 gap-4 bg-slate-50 p-4 rounded-xl border border-ink-900/10">
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Min Stock</label>
<input
type="number"
value={minStock}
onChange={(e) => setMinStock(e.target.value)}
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Max Stock Remaining</label>
<input
type="number"
value={maxStock}
onChange={(e) => setMaxStock(e.target.value)}
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-800 mb-1">Discount %</label>
<input
type="number"
value={discountPct}
onChange={(e) => setDiscountPct(e.target.value)}
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
/>
</div>
<div className="flex items-end">
<button
type="submit"
disabled={allocating}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-amber-500 hover:bg-amber-400 text-slate-950 py-2.5 text-xs font-bold shadow-sm transition"
>
{allocating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Percent className="h-4 w-4" />}
Allocate Discounts
</button>
</div>
</form>
{allocationResult && (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs text-slate-600">
<span className="font-bold text-ink-950">Applied Discount Allocations ({allocationResult.total_products_allocated} products)</span>
</div>
<div className="max-h-80 overflow-y-auto border border-ink-900/10 rounded-xl bg-white divide-y divide-slate-100">
{allocationResult.allocations.map((a, i) => (
<div key={i} className="flex items-center justify-between p-3 text-xs">
<div>
<p className="font-bold text-ink-950">{a.product_name}</p>
<p className="text-[10px] text-slate-400 font-mono">Store: {a.store_id} · Stock Remaining: {a.stock_remaining}</p>
</div>
<div className="text-right font-mono">
<span className="inline-block bg-emerald-100 text-emerald-800 font-bold px-2 py-0.5 rounded text-[11px] mr-2">
{a.discount_pct}% OFF
</span>
<span className="text-slate-400 line-through mr-1">₹{a.original_price}</span>
<span className="font-bold text-leaf-600 text-sm">₹{a.final_price}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* TAB 4: INGESTION PIPELINE */}
{activeTab === 'ingest' && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
<div>
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
<Server className="h-5 w-5 text-amber-500" /> Vector Database Catalog Ingestion
</h2>
<p className="text-xs text-slate-500">
Discover and store brand products into pgvector (discovery → images → embeddings).
</p>
</div>
<form onSubmit={handleSubmitIngestion} className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1">
<label className="mb-1 block text-xs font-semibold text-ink-800">Brand name</label>
<input
value={brand}
onChange={(e) => setBrand(e.target.value)}
placeholder="e.g. Britannia"
disabled={!!runningJob}
className="w-full rounded-xl border border-ink-900/15 p-2.5 text-xs font-medium focus:border-amber-500 focus:outline-none"
/>
</div>
<div className="w-32">
<label className="mb-1 block text-xs font-semibold text-ink-800">Max products</label>
<input
type="number"
min={1}
max={300}
value={maxProducts}
onChange={(e) => setMaxProducts(e.target.value)}
disabled={!!runningJob}
className="w-full rounded-xl border border-ink-900/15 p-2.5 text-xs font-medium focus:border-amber-500 focus:outline-none"
/>
</div>
<button
type="submit"
disabled={submitting || !brand.trim() || !!runningJob}
className="flex items-center justify-center gap-1.5 rounded-xl bg-ink-900 px-5 py-2.5 text-xs font-bold text-white transition hover:bg-ink-950 disabled:opacity-40"
>
<PlayCircle className="h-4 w-4" /> Start Ingestion
</button>
</form>
{runningJob && (
<p className="text-xs text-amber-600">
A job for "{runningJob.brand}" is already in progress - please wait.
</p>
)}
<h3 className="font-display text-xs font-semibold uppercase tracking-wide text-slate-500 mt-6">Recent jobs</h3>
{jobs.length === 0 ? (
<p className="text-xs text-slate-500">No ingestion jobs started yet this session.</p>
) : (
<ul className="space-y-2">
{jobs.map((j) => {
const style = STATUS_STYLES[j.status] || STATUS_STYLES.pending;
const Icon = style.icon;
return (
<li key={j.job_id} className="flex items-center gap-3 rounded-xl border border-ink-900/10 bg-white px-4 py-3">
<span className={`flex h-8 w-8 items-center justify-center rounded-full ${style.className}`}>
<Icon className={`h-4 w-4 ${style.spin ? 'animate-spin' : ''}`} />
</span>
<div className="flex-1">
<p className="text-xs font-bold text-ink-950">{j.brand}</p>
<p className="text-[11px] text-slate-500">{j.detail || j.status}</p>
</div>
<span className="font-mono text-xs uppercase text-slate-400">{j.status}</span>
</li>
);
})}
</ul>
)}
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,143 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { ShieldCheck, UserCheck, Lock, User, ArrowRight, Sparkles } from 'lucide-react';
export function LoginPage() {
const navigate = useNavigate();
const { login } = useAuth();
const [selectedRole, setSelectedRole] = useState('admin');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleRoleSelect = (role) => {
setSelectedRole(role);
setError('');
};
const handleFormSubmit = async (e) => {
e.preventDefault();
setError('');
if (!username.trim() || !password.trim()) {
setError('Please enter both username and password.');
return;
}
setLoading(true);
try {
const user = await login(username, password, selectedRole);
setLoading(false);
if (user.role === 'admin') navigate('/admin');
else navigate('/user');
} catch (err) {
setLoading(false);
setError(`Login failed. Invalid username or password for ${selectedRole.toUpperCase()} account.`);
}
};
return (
<div className="min-h-screen w-full bg-slate-950 text-slate-100 flex items-center justify-center p-4 relative overflow-hidden font-sans">
{/* Background Glow Accents */}
<div className="absolute -top-40 -left-40 h-96 w-96 rounded-full bg-amber-500/10 blur-3xl pointer-events-none" />
<div className="absolute -bottom-40 -right-40 h-96 w-96 rounded-full bg-emerald-500/10 blur-3xl pointer-events-none" />
<div className="w-full max-w-md relative z-10">
{/* Header Branding */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full bg-amber-500/10 border border-amber-500/30 text-amber-400 text-xs font-semibold mb-3">
<Sparkles className="h-3.5 w-3.5" /> Brand Catalog & Store Intelligence
</div>
<h1 className="text-3xl font-bold tracking-tight text-white font-display">System Authentication</h1>
<p className="text-sm text-slate-400 mt-1">Please enter your username and password to sign in</p>
</div>
{/* Main Card */}
<div className="rounded-2xl border border-slate-800 bg-slate-900/80 p-6 backdrop-blur-xl shadow-2xl">
{/* Role Selection Tabs: Admin and User */}
<div className="grid grid-cols-2 gap-2 p-1.5 rounded-xl bg-slate-950/80 border border-slate-800/80 mb-6">
<button
type="button"
onClick={() => handleRoleSelect('admin')}
className={`flex items-center justify-center gap-2 py-3 px-4 rounded-lg text-xs font-bold transition-all ${
selectedRole === 'admin'
? 'bg-amber-500 text-slate-950 shadow-md scale-[1.02]'
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
}`}
>
<ShieldCheck className="h-4 w-4" /> Admin Account
</button>
<button
type="button"
onClick={() => handleRoleSelect('user')}
className={`flex items-center justify-center gap-2 py-3 px-4 rounded-lg text-xs font-bold transition-all ${
selectedRole === 'user'
? 'bg-blue-500 text-white shadow-md scale-[1.02]'
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
}`}
>
<UserCheck className="h-4 w-4" /> User Account
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-rose-500/10 border border-rose-500/30 p-3 text-xs text-rose-400">
{error}
</div>
)}
{/* Login Form */}
<form onSubmit={handleFormSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-300 mb-1.5">Username</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<input
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={`Enter ${selectedRole} username`}
className="w-full rounded-xl border border-slate-700 bg-slate-950/80 py-2.5 pl-9 pr-4 text-sm text-white placeholder-slate-500 focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-300 mb-1.5">Password</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
className="w-full rounded-xl border border-slate-700 bg-slate-950/80 py-2.5 pl-9 pr-4 text-sm text-white placeholder-slate-500 focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-slate-950 py-3 text-sm font-bold shadow-lg shadow-amber-500/20 transition-all duration-200 active:scale-98 disabled:opacity-50"
>
{loading ? 'Authenticating...' : `Sign In as ${selectedRole.toUpperCase()}`}
<ArrowRight className="h-4 w-4" />
</button>
</form>
</div>
{/* Footer info */}
<p className="text-center text-xs text-slate-500 mt-6">
Powered by pgvector RAG Model & Multi-Store Nutrition Intelligence Engine
</p>
</div>
</div>
);
}

View File

@@ -160,7 +160,7 @@ export function StoresPage() {
<tr key={`${p.brand}-${p.image_id}`} className="hover:bg-amber-50/40">
<td className="px-4 py-2.5">
<p className="font-medium text-ink-900">{p.title}</p>
<p className="text-xs text-slate-400">{p.brand}</p>
<p className="text-xs text-slate-400">{p.brand}{p.fssai_license ? ` · FSSAI: ${p.fssai_license}` : ''}</p>
</td>
<td className="px-4 py-2.5 text-slate-500">{p.category}</td>
<td className="px-4 py-2.5 font-mono">{p.available_stock}</td>

File diff suppressed because it is too large Load Diff

View File

@@ -8,13 +8,13 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
// Avoid CORS entirely in dev by proxying /api/* straight to FastAPI.
// Adjust the target if your backend runs on a different host/port.
// Proxy all /api/* requests straight to FastAPI on port 8000
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
secure: false,
ws: true,
},
},
},
})

View File

@@ -8,14 +8,17 @@ Usage:
What this script automates:
1. Checks & launches PostgreSQL via Docker Compose (`docker compose up -d`).
2. Starts FastAPI backend server on http://localhost:8000 (with background auto-seeding).
3. Starts Vite frontend dev server on http://localhost:5173 (or serves static dist).
4. Keeps both servers running concurrently with instant hot-reloading & single Ctrl+C exit.
3. Waits until backend port 8000 is live & ready to receive connections.
4. Starts Vite frontend dev server on http://localhost:5173.
5. Keeps both servers running concurrently with instant hot-reloading & single Ctrl+C exit.
"""
import os
import sys
import time
import subprocess
import signal
import shutil
import urllib.request
from pathlib import Path
if sys.platform == "win32":
@@ -41,28 +44,71 @@ def print_banner():
print("=" * 70 + "\033[0m")
import shutil
def start_docker():
print("\n📦 Checking PostgreSQL Docker container...")
compose_file = ROOT_DIR / "docker-compose.yml"
if not compose_file.exists():
compose_file = BACKEND_DIR / "docker-compose.yml"
if not shutil.which("docker"):
print("ℹ️ Docker CLI not found. Skipping container management.")
return
# 1. Check if container is already running
try:
check_running = subprocess.run(
["docker", "ps", "--filter", "name=catalog_rag_postgres", "--format", "{{.Names}}"],
capture_output=True, text=True
)
if "catalog_rag_postgres" in check_running.stdout:
print("✅ PostgreSQL container 'catalog_rag_postgres' is running on port 5432 (pgvector ready).")
return
except Exception:
pass
# 2. Check if stopped container exists and start it
try:
check_all = subprocess.run(
["docker", "ps", "-a", "--filter", "name=catalog_rag_postgres", "--format", "{{.Names}}"],
capture_output=True, text=True
)
if "catalog_rag_postgres" in check_all.stdout:
print("🔄 Starting existing 'catalog_rag_postgres' container...")
start_res = subprocess.run(["docker", "start", "catalog_rag_postgres"], capture_output=True, text=True)
if start_res.returncode == 0:
print("✅ PostgreSQL container started on port 5432 (pgvector ready).")
return
except Exception:
pass
# 3. Fallback to Docker Compose
if compose_file.exists():
try:
res = subprocess.run(["docker", "compose", "-f", str(compose_file), "up", "-d"], capture_output=True, text=True)
res = subprocess.run(
["docker", "compose", "-f", str(compose_file), "up", "-d", "--remove-orphans"],
capture_output=True, text=True
)
if res.returncode == 0:
print("✅ PostgreSQL container is running on port 5432 (pgvector ready).")
else:
print("⚠️ Docker notice:", res.stderr.strip() or "Ensure Docker Desktop is running if using Docker Postgres.")
stderr_msg = res.stderr.strip()
if "Conflict" in stderr_msg or "already in use" in stderr_msg:
print("⚠️ Container name conflict detected. Automatically resetting container...")
subprocess.run(["docker", "rm", "-f", "catalog_rag_postgres"], capture_output=True, text=True)
retry_res = subprocess.run(
["docker", "compose", "-f", str(compose_file), "up", "-d"],
capture_output=True, text=True
)
if retry_res.returncode == 0:
print("✅ PostgreSQL container successfully recreated on port 5432 (pgvector ready).")
return
print("⚠️ Docker notice:", stderr_msg or "Ensure Docker Desktop is running if using Docker Postgres.")
except Exception as e:
print(f"⚠️ Docker check skipped: {e}")
def start_ollama():
print("\n🦙 Checking Ollama LLM service...")
import urllib.request
try:
req = urllib.request.urlopen("http://localhost:11434/api/tags", timeout=2)
if req.getcode() == 200:
@@ -84,6 +130,19 @@ def start_ollama():
return None
def wait_for_backend_ready():
print("⏳ Waiting for backend API to initialize on port 8000...")
for _ in range(30):
try:
req = urllib.request.urlopen("http://127.0.0.1:8000/api/system/status", timeout=1)
if req.getcode() == 200:
print("✅ Backend API is live and accepting connections on port 8000.")
return True
except Exception:
time.sleep(0.5)
return False
def main():
print_banner()
start_docker()
@@ -109,7 +168,10 @@ def main():
)
processes.append(p_backend)
# 2. Start Frontend Vite Dev Server
# 2. Wait until Backend is ready before starting Vite frontend
wait_for_backend_ready()
# 3. Start Frontend Vite Dev Server
print("🎨 Starting Frontend Vite Server (http://localhost:5173)...")
npm_cmd = "npm.cmd" if sys.platform == "win32" else "npm"
p_frontend = subprocess.Popen(
@@ -127,7 +189,7 @@ def main():
print("=" * 70)
print(" (Press Ctrl+C to stop all servers gracefully)\n")
# Monitor core servers (backend & frontend)
# Monitor core processes (backend & frontend)
core_processes = [p_backend, p_frontend]
while True:
time.sleep(1)