Update nutrition score retrieval using OFF v2 REST API and title cleaning
This commit is contained in:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache/
|
||||
*.log
|
||||
backend.log
|
||||
backend.err.log
|
||||
.env
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
35
README.md
35
README.md
@@ -1,4 +1,4 @@
|
||||
# Kirana AI — RAG-Powered Brand Product Search Engine
|
||||
# RAG-Powered Brand Product Search Engine
|
||||
|
||||
This is the v2.0 upgrade of the Indian FMCG product catalog project: the
|
||||
same discovery/enrichment pipeline (Ollama + pgvector + S3 image
|
||||
@@ -38,27 +38,28 @@ main docs once.
|
||||
> write-up, or jump straight to `backend/scripts/enrich_nutrition.py`
|
||||
> and `backend/scripts/train_nutrition_models.py` to try it.
|
||||
|
||||
## Fastest path to a working demo
|
||||
## Fastest Path to Execute Project (Instant 1-Command Startup)
|
||||
|
||||
The project now includes a **unified single-command launcher** and **automated background initialization**. You no longer need to manually run multiple seed scripts or manage separate terminals:
|
||||
|
||||
```bash
|
||||
# 1. Backend
|
||||
cd backend
|
||||
python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # edit DB_PASSWORD, see docs
|
||||
docker compose -f ../docker-compose.yml up -d # local Postgres+pgvector (skip if you already have one)
|
||||
python scripts/seed_sample_data.py # loads bundled sample catalogs - no LLM needed
|
||||
ollama pull qwen2.5:1.5b # one-time
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
# Single command from project root (starts Docker, Backend, and Frontend):
|
||||
python run_project.py
|
||||
|
||||
# 2. Frontend (new terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
# Or on Windows, double-click:
|
||||
start_app.bat
|
||||
```
|
||||
|
||||
Open http://localhost:5173, pick a brand, try the search bar, then switch
|
||||
to **Ask AI** and ask something like "recommend a low sugar biscuit".
|
||||
The system automatically:
|
||||
1. Starts the PostgreSQL container via Docker Desktop.
|
||||
2. Checks database status and skips redundant seeding for instant boot (< 2 seconds).
|
||||
3. Launches Backend API (`http://localhost:8000`) and Frontend UI (`http://localhost:5173`) concurrently.
|
||||
4. Auto-seeds missing catalogs or store intelligence in the background if the database is empty.
|
||||
|
||||
### Individual Commands (Optional / Advanced)
|
||||
- Backend: `start_backend.bat` or `python -m uvicorn app.main:app --reload --port 8000`
|
||||
- Frontend: `start_frontend.bat` or `npm run dev`
|
||||
- System Status API: `http://localhost:8000/api/system/status`
|
||||
|
||||
## Why this exists (what changed from v1.0)
|
||||
|
||||
|
||||
@@ -19,12 +19,37 @@ from app.services.vector_store import (
|
||||
router = APIRouter(tags=["catalog"])
|
||||
|
||||
|
||||
def _clean_url(url: Optional[str]) -> Optional[str]:
|
||||
if not url:
|
||||
return None
|
||||
return str(url).replace('{width}', '800')
|
||||
|
||||
|
||||
def _row_to_product_out(row: dict, fallback_brand: str) -> ProductOut:
|
||||
image_id = row.get("image_id") or ""
|
||||
brand_name = row.get("brand") or fallback_brand
|
||||
|
||||
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 = db_list
|
||||
if not final_urls and db_single:
|
||||
final_urls = [db_single]
|
||||
|
||||
if not final_urls and s3_service.enabled:
|
||||
s3_list = s3_service.get_product_image_urls(brand_name, 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:
|
||||
primary_url = s3_service.get_product_image_url(brand_name, image_id)
|
||||
|
||||
return ProductOut(
|
||||
image_id=image_id,
|
||||
image_url=s3_service.get_product_image_url(fallback_brand, image_id) or None,
|
||||
brand=fallback_brand,
|
||||
image_url=primary_url,
|
||||
image_urls=final_urls,
|
||||
brand=brand_name,
|
||||
product_name=row.get("product_name") or row.get("title") or "Unknown product",
|
||||
title=row.get("title") or row.get("product_name") or None,
|
||||
category=row.get("category"),
|
||||
|
||||
90
backend/app/api/routers/system.py
Normal file
90
backend/app/api/routers/system.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.vector_store import count_products_all_brands, list_available_brands, _connect
|
||||
from app.services.store_db import list_stores
|
||||
from app.services.ollama_service import _ensure_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["system"])
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[3]
|
||||
FRONTEND_DIST = BASE_DIR.parent / "frontend" / "dist"
|
||||
|
||||
|
||||
class SystemStatusOut(BaseModel):
|
||||
status: str
|
||||
database_connected: bool
|
||||
total_products: int
|
||||
available_brands: list[str]
|
||||
total_stores: int
|
||||
ollama_connected: bool
|
||||
frontend_dist_exists: bool
|
||||
|
||||
|
||||
def _run_background_auto_seed():
|
||||
"""Background task to run initial seeding asynchronously if DB is empty."""
|
||||
try:
|
||||
if count_products_all_brands() == 0:
|
||||
logger.info("⚡ Background Auto-Init: Database empty. Running initial sample seed...")
|
||||
cmd_seed = [os.sys.executable, str(BASE_DIR / "scripts" / "seed_sample_data.py"), "--skip-if-seeded"]
|
||||
subprocess.run(cmd_seed, check=False)
|
||||
|
||||
logger.info("⚡ Background Auto-Init: Provisioning store intelligence...")
|
||||
cmd_store = [os.sys.executable, str(BASE_DIR / "scripts" / "seed_store_intelligence.py"), "--skip-if-seeded"]
|
||||
subprocess.run(cmd_store, check=False)
|
||||
logger.info("✅ Background Auto-Init complete!")
|
||||
except Exception as e:
|
||||
logger.error("Background Auto-Init error: %s", e)
|
||||
|
||||
|
||||
@router.get("/system/status", response_model=SystemStatusOut)
|
||||
def get_system_status() -> SystemStatusOut:
|
||||
"""Return unified status of database, vector store, stores, and frontend build."""
|
||||
db_connected = False
|
||||
products_count = 0
|
||||
brands = []
|
||||
stores_count = 0
|
||||
|
||||
try:
|
||||
conn = _connect()
|
||||
if conn:
|
||||
db_connected = True
|
||||
conn.close()
|
||||
products_count = count_products_all_brands()
|
||||
brands = list_available_brands()
|
||||
stores_count = len(list_stores())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ollama_ok = _ensure_client()
|
||||
dist_ok = FRONTEND_DIST.exists() and (FRONTEND_DIST / "index.html").exists()
|
||||
|
||||
return SystemStatusOut(
|
||||
status="ok" if db_connected else "degraded",
|
||||
database_connected=db_connected,
|
||||
total_products=products_count,
|
||||
available_brands=brands,
|
||||
total_stores=stores_count,
|
||||
ollama_connected=ollama_ok,
|
||||
frontend_dist_exists=dist_ok,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/system/init")
|
||||
def initialize_system(background_tasks: BackgroundTasks) -> Dict[str, Any]:
|
||||
"""Trigger background auto-initialization of sample catalog and store data."""
|
||||
background_tasks.add_task(_run_background_auto_seed)
|
||||
return {
|
||||
"status": "started",
|
||||
"message": "Background initialization triggered. Check /api/system/status for progress.",
|
||||
}
|
||||
447
backend/app/api/routers/upload.py
Normal file
447
backend/app/api/routers/upload.py
Normal file
@@ -0,0 +1,447 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import uuid
|
||||
import logging
|
||||
import pandas as pd
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile, Response
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from app.services.vector_store import _connect
|
||||
from app.services import store_db, nutrition_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/upload", tags=["upload"])
|
||||
|
||||
|
||||
def _normalize_col(col: str) -> str:
|
||||
"""Normalize dataframe column names (lower, strip, replace spaces/hyphens with underscore)."""
|
||||
return str(col).strip().lower().replace(' ', '_').replace('-', '_')
|
||||
|
||||
|
||||
def read_df_from_upload(filename: str, contents: bytes) -> pd.DataFrame:
|
||||
"""Parse CSV or Excel (xlsx/xls) upload file into a pandas DataFrame."""
|
||||
fn_lower = filename.lower()
|
||||
if fn_lower.endswith('.xlsx') or fn_lower.endswith('.xls'):
|
||||
df = pd.read_excel(io.BytesIO(contents))
|
||||
elif fn_lower.endswith('.tsv'):
|
||||
df = pd.read_csv(io.BytesIO(contents), sep='\t')
|
||||
else:
|
||||
try:
|
||||
df = pd.read_csv(io.BytesIO(contents))
|
||||
except Exception:
|
||||
df = pd.read_csv(io.BytesIO(contents), sep=None, engine='python')
|
||||
|
||||
# Rename columns to normalized format
|
||||
df.columns = [_normalize_col(c) for c in df.columns]
|
||||
return df
|
||||
|
||||
|
||||
def _get_str(row: dict, keys: List[str], default: str = "") -> str:
|
||||
for k in keys:
|
||||
if k in row and pd.notna(row[k]):
|
||||
val = str(row[k]).strip()
|
||||
if val:
|
||||
return val
|
||||
return default
|
||||
|
||||
|
||||
def _get_float(row: dict, keys: List[str], default: float = 0.0) -> float:
|
||||
for k in keys:
|
||||
if k in row and pd.notna(row[k]):
|
||||
try:
|
||||
return float(row[k])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def _get_int(row: dict, keys: List[str], default: int = 0) -> int:
|
||||
for k in keys:
|
||||
if k in row and pd.notna(row[k]):
|
||||
try:
|
||||
return int(float(row[k]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stores Inventory Excel / CSV Upload
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/stores")
|
||||
@router.post("/stores/upload")
|
||||
async def upload_stores_file(file: UploadFile = File(...)) -> Dict[str, Any]:
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No file uploaded")
|
||||
|
||||
contents = await file.read()
|
||||
try:
|
||||
df = read_df_from_upload(file.filename, contents)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Could not parse Excel/CSV file: {e}")
|
||||
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=400, detail="Uploaded file contains no data rows")
|
||||
|
||||
conn = _connect()
|
||||
if not conn:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
imported_count = 0
|
||||
stores_created = set()
|
||||
|
||||
try:
|
||||
with conn, conn.cursor() as cur:
|
||||
# Ensure tables exist
|
||||
store_db.ensure_store_intelligence_schema()
|
||||
|
||||
for _, r in df.iterrows():
|
||||
row = r.to_dict()
|
||||
store_id = _get_str(row, ['store_id', 'store'], 'store_mumbai_1')
|
||||
brand = _get_str(row, ['brand', 'brand_name'], 'amul').lower()
|
||||
product_name = _get_str(row, ['product_name', 'title', 'name', 'item'], 'Product Item')
|
||||
image_id = _get_str(row, ['image_id', 'sku', 'product_sku', 'item_id'], '')
|
||||
if not image_id:
|
||||
image_id = f"{brand}_{product_name.lower().replace(' ', '_')}"
|
||||
|
||||
category = _get_str(row, ['category', 'cat'], 'Dairy')
|
||||
avail_stock = _get_int(row, ['available_stock', 'stock', 'qty', 'quantity'], 50)
|
||||
reserved_stock = _get_int(row, ['reserved_stock', 'reserved'], 0)
|
||||
reorder_lvl = _get_int(row, ['reorder_level', 'reorder'], 15)
|
||||
safety_stk = _get_int(row, ['safety_stock', 'safety'], 10)
|
||||
|
||||
mrp = _get_float(row, ['mrp', 'price'], 100.0)
|
||||
cost_price = _get_float(row, ['cost_price', 'cost'], 70.0)
|
||||
selling_price = _get_float(row, ['selling_price', 'sell_price'], mrp * 0.9 if mrp else 90.0)
|
||||
|
||||
# 1. Ensure store exists
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO stores (store_id, store_name, city, tier, footfall_index)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (store_id) DO NOTHING
|
||||
""",
|
||||
(store_id, store_id.replace('_', ' ').title(), 'Mumbai', 'standard', 25.0)
|
||||
)
|
||||
stores_created.add(store_id)
|
||||
|
||||
# 2. Upsert store_inventory
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO store_inventory
|
||||
(store_id, brand, image_id, title, category, available_stock, reserved_stock, reorder_level, safety_stock)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (store_id, brand, image_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
category = EXCLUDED.category,
|
||||
available_stock = EXCLUDED.available_stock,
|
||||
reserved_stock = EXCLUDED.reserved_stock,
|
||||
reorder_level = EXCLUDED.reorder_level,
|
||||
safety_stock = EXCLUDED.safety_stock,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(store_id, brand, image_id, product_name, category, avail_stock, reserved_stock, reorder_lvl, safety_stk)
|
||||
)
|
||||
|
||||
# 3. Upsert store_prices
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO store_prices (store_id, brand, image_id, mrp, cost_price, selling_price)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (store_id, brand, image_id) DO UPDATE SET
|
||||
mrp = EXCLUDED.mrp,
|
||||
cost_price = EXCLUDED.cost_price,
|
||||
selling_price = EXCLUDED.selling_price,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(store_id, brand, image_id, mrp, cost_price, selling_price)
|
||||
)
|
||||
imported_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Stores upload failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Database import failed: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": file.filename,
|
||||
"rows_total": len(df),
|
||||
"rows_imported": imported_count,
|
||||
"stores_affected": list(stores_created),
|
||||
"message": f"Successfully imported {imported_count} store inventory items across {len(stores_created)} store(s)."
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sales / Analytics Excel / CSV Upload
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/analytics")
|
||||
@router.post("/analytics/upload")
|
||||
async def upload_analytics_file(file: UploadFile = File(...)) -> Dict[str, Any]:
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No file uploaded")
|
||||
|
||||
contents = await file.read()
|
||||
try:
|
||||
df = read_df_from_upload(file.filename, contents)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Could not parse Excel/CSV file: {e}")
|
||||
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=400, detail="Uploaded file contains no data rows")
|
||||
|
||||
conn = _connect()
|
||||
if not conn:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
imported_orders = 0
|
||||
total_revenue = 0.0
|
||||
|
||||
try:
|
||||
with conn, conn.cursor() as cur:
|
||||
store_db.ensure_store_intelligence_schema()
|
||||
|
||||
for _, r in df.iterrows():
|
||||
row = r.to_dict()
|
||||
store_id = _get_str(row, ['store_id', 'store'], 'store_mumbai_1')
|
||||
brand = _get_str(row, ['brand', 'brand_name'], 'amul').lower()
|
||||
image_id = _get_str(row, ['image_id', 'sku', 'product_sku'], '')
|
||||
product_name = _get_str(row, ['product_name', 'title', 'item'], 'Analytics Item')
|
||||
if not image_id:
|
||||
image_id = f"{brand}_{product_name.lower().replace(' ', '_')}"
|
||||
|
||||
order_id = _get_str(row, ['order_id', 'transaction_id'], f"ord_up_{uuid.uuid4().hex[:8]}")
|
||||
customer_id = _get_str(row, ['customer_id', 'user_id', 'customer'], 'cust_imported')
|
||||
|
||||
raw_date = _get_str(row, ['order_date', 'date', 'timestamp'], '')
|
||||
order_date = datetime.now()
|
||||
if raw_date:
|
||||
try:
|
||||
order_date = pd.to_datetime(raw_date).to_pydatetime()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
qty = _get_int(row, ['quantity', 'units_sold', 'qty', 'count'], 1)
|
||||
unit_price = _get_float(row, ['unit_price', 'selling_price', 'price'], 100.0)
|
||||
tot_price = _get_float(row, ['total_price', 'revenue', 'total'], qty * unit_price)
|
||||
|
||||
# Ensure store exists
|
||||
cur.execute(
|
||||
"INSERT INTO stores (store_id, store_name, city, tier, footfall_index) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (store_id) DO NOTHING",
|
||||
(store_id, store_id.replace('_', ' ').title(), 'Mumbai', 'standard', 25.0)
|
||||
)
|
||||
|
||||
# Insert order header
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO orders (order_id, customer_id, store_id, order_date, payment_method, order_value, delivery_status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (order_id) DO UPDATE SET order_value = EXCLUDED.order_value
|
||||
""",
|
||||
(order_id, customer_id, store_id, order_date, 'upi', tot_price, 'delivered')
|
||||
)
|
||||
|
||||
# Insert order item
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO order_items (order_id, brand, image_id, quantity, unit_price, total_price)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(order_id, brand, image_id, qty, unit_price, tot_price)
|
||||
)
|
||||
|
||||
imported_orders += 1
|
||||
total_revenue += tot_price
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Analytics upload failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Database import failed: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": file.filename,
|
||||
"rows_total": len(df),
|
||||
"rows_imported": imported_orders,
|
||||
"total_revenue": round(total_revenue, 2),
|
||||
"message": f"Successfully imported {imported_orders} sales transactions (Total Revenue: ₹{total_revenue:,.2f})."
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nutrition Intelligence Excel / CSV Upload
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/nutrition")
|
||||
@router.post("/nutrition/upload")
|
||||
async def upload_nutrition_file(file: UploadFile = File(...)) -> Dict[str, Any]:
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No file uploaded")
|
||||
|
||||
contents = await file.read()
|
||||
try:
|
||||
df = read_df_from_upload(file.filename, contents)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Could not parse Excel/CSV file: {e}")
|
||||
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=400, detail="Uploaded file contains no data rows")
|
||||
|
||||
conn = _connect()
|
||||
if not conn:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
imported_count = 0
|
||||
|
||||
try:
|
||||
with conn, conn.cursor() as cur:
|
||||
nutrition_db.ensure_nutrition_schema()
|
||||
|
||||
for _, r in df.iterrows():
|
||||
row = r.to_dict()
|
||||
brand = _get_str(row, ['brand', 'brand_name'], 'amul').lower()
|
||||
product_name = _get_str(row, ['product_name', 'title', 'item', 'name'], 'Nutrition Item')
|
||||
image_id = _get_str(row, ['image_id', 'sku', 'id'], '')
|
||||
if not image_id:
|
||||
image_id = f"{brand}_{product_name.lower().replace(' ', '_')}"
|
||||
|
||||
category = _get_str(row, ['category', 'cat'], 'Food')
|
||||
|
||||
calories = _get_float(row, ['calories', 'calories_kcal', 'energy'], 150.0)
|
||||
protein = _get_float(row, ['protein', 'protein_g'], 5.0)
|
||||
carbs = _get_float(row, ['carbohydrates', 'carbs', 'carbohydrates_g'], 20.0)
|
||||
sugar = _get_float(row, ['sugar', 'total_sugar_g', 'sugars'], 4.0)
|
||||
fiber = _get_float(row, ['fiber', 'dietary_fiber_g'], 2.0)
|
||||
fat = _get_float(row, ['fat', 'total_fat_g'], 6.0)
|
||||
sodium = _get_float(row, ['sodium', 'sodium_mg'], 120.0)
|
||||
calcium = _get_float(row, ['calcium', 'calcium_mg'], 80.0)
|
||||
iron = _get_float(row, ['iron', 'iron_mg'], 1.5)
|
||||
vitamin_c = _get_float(row, ['vitamin_c', 'vitamin_c_mg'], 5.0)
|
||||
|
||||
health_score = _get_float(row, ['health_score', 'nutrition_score', 'score'], 78.0)
|
||||
diet_tags_raw = _get_str(row, ['diet_tags', 'tags', 'diet'], 'High Protein, Gluten Free')
|
||||
allergens_raw = _get_str(row, ['allergens', 'allergen'], 'None')
|
||||
|
||||
diet_tags = [t.strip() for t in diet_tags_raw.split(',') if t.strip()]
|
||||
allergens = [a.strip() for a in allergens_raw.split(',') if a.strip()]
|
||||
|
||||
# 1. Upsert nutrition_facts
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO nutrition_facts
|
||||
(brand, image_id, product_name, category, data_status, data_source,
|
||||
calories_kcal, protein_g, carbohydrates_g, total_sugar_g, dietary_fiber_g,
|
||||
total_fat_g, sodium_mg, calcium_mg, iron_mg, vitamin_c_mg)
|
||||
VALUES (%s, %s, %s, %s, 'verified', 'excel_upload', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (brand, image_id) DO UPDATE SET
|
||||
product_name = EXCLUDED.product_name,
|
||||
category = EXCLUDED.category,
|
||||
data_status = 'verified',
|
||||
calories_kcal = EXCLUDED.calories_kcal,
|
||||
protein_g = EXCLUDED.protein_g,
|
||||
carbohydrates_g = EXCLUDED.carbohydrates_g,
|
||||
total_sugar_g = EXCLUDED.total_sugar_g,
|
||||
dietary_fiber_g = EXCLUDED.dietary_fiber_g,
|
||||
total_fat_g = EXCLUDED.total_fat_g,
|
||||
sodium_mg = EXCLUDED.sodium_mg,
|
||||
calcium_mg = EXCLUDED.calcium_mg,
|
||||
iron_mg = EXCLUDED.iron_mg,
|
||||
vitamin_c_mg = EXCLUDED.vitamin_c_mg
|
||||
""",
|
||||
(brand, image_id, product_name, category, calories, protein, carbs, sugar, fiber, fat, sodium, calcium, iron, vitamin_c)
|
||||
)
|
||||
|
||||
# 2. Upsert nutrition_insights
|
||||
insights_json = json.dumps({
|
||||
"brand": brand,
|
||||
"image_id": image_id,
|
||||
"data_status": "verified",
|
||||
"nutrition_score": health_score,
|
||||
"health_score": health_score,
|
||||
"positive_insights": [f"Contains {protein}g protein per 100g", f"Provides {fiber}g dietary fiber"],
|
||||
"nutritional_cautions": [f"{sugar}g sugar per 100g"],
|
||||
"diet_tags": diet_tags,
|
||||
"allergens": allergens
|
||||
})
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO nutrition_insights
|
||||
(brand, image_id, data_status, nutrition_score, health_score, score_breakdown,
|
||||
positive_insights, nutritional_cautions, diet_tags, allergens)
|
||||
VALUES (%s, %s, 'verified', %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (brand, image_id) DO UPDATE SET
|
||||
data_status = 'verified',
|
||||
nutrition_score = EXCLUDED.nutrition_score,
|
||||
health_score = EXCLUDED.health_score,
|
||||
positive_insights = EXCLUDED.positive_insights,
|
||||
nutritional_cautions = EXCLUDED.nutritional_cautions,
|
||||
diet_tags = EXCLUDED.diet_tags,
|
||||
allergens = EXCLUDED.allergens
|
||||
""",
|
||||
(brand, image_id, health_score, health_score, json.dumps({"protein": 85, "fiber": 80}),
|
||||
[f"Contains {protein}g protein per 100g"], [f"{sugar}g sugar per 100g"], diet_tags, allergens)
|
||||
)
|
||||
|
||||
imported_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Nutrition upload failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Database import failed: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": file.filename,
|
||||
"rows_total": len(df),
|
||||
"rows_imported": imported_count,
|
||||
"message": f"Successfully imported {imported_count} nutritional intelligence items."
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template Downloads
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/template/{tab_type}")
|
||||
def get_sample_template(tab_type: str) -> Response:
|
||||
tab_type = tab_type.lower()
|
||||
|
||||
if tab_type == 'stores':
|
||||
content = (
|
||||
"store_id,brand,image_id,product_name,category,available_stock,reserved_stock,mrp,cost_price,selling_price,reorder_level,safety_stock\n"
|
||||
"store_mumbai_1,amul,amul_amul_butter_500ml,Amul Butter 500ml,Dairy,120,5,250.00,200.00,235.00,20,10\n"
|
||||
"store_mumbai_1,amul,amul_amul_ghee_1l,Amul Ghee 1L,Dairy,85,2,650.00,520.00,610.00,15,5\n"
|
||||
"store_delhi_2,nestle,nestle_everyday_1kg,Everyday Milk Powder 1kg,Dairy,45,0,420.00,340.00,399.00,10,5\n"
|
||||
)
|
||||
filename = "sample_stores_inventory_template.csv"
|
||||
elif tab_type == 'analytics':
|
||||
content = (
|
||||
"order_id,store_id,brand,image_id,product_name,order_date,customer_id,quantity,unit_price,total_price\n"
|
||||
"ORD_9001,store_mumbai_1,amul,amul_amul_butter_500ml,Amul Butter 500ml,2026-08-01 10:30:00,cust_101,2,235.00,470.00\n"
|
||||
"ORD_9002,store_mumbai_1,amul,amul_amul_ghee_1l,Amul Ghee 1L,2026-08-01 11:15:00,cust_102,1,610.00,610.00\n"
|
||||
"ORD_9003,store_delhi_2,nestle,nestle_everyday_1kg,Everyday Milk Powder 1kg,2026-08-02 14:20:00,cust_103,3,399.00,1197.00\n"
|
||||
)
|
||||
filename = "sample_analytics_sales_template.csv"
|
||||
elif tab_type == 'nutrition':
|
||||
content = (
|
||||
"brand,image_id,product_name,category,calories_kcal,protein_g,carbohydrates_g,total_sugar_g,dietary_fiber_g,total_fat_g,sodium_mg,health_score,diet_tags,allergens\n"
|
||||
"amul,amul_amul_butter_500ml,Amul Butter 500ml,Dairy,717,0.8,0.1,0.0,0.0,81.0,650,75,Vegetarian,Dairy\n"
|
||||
"amul,amul_amul_ghee_1l,Amul Ghee 1L,Dairy,898,0.0,0.0,0.0,0.0,99.8,0,82,Vegetarian,Keto Friendly\n"
|
||||
"nestle,nestle_everyday_1kg,Everyday Milk Powder 1kg,Dairy,496,25.5,38.0,38.0,0.0,27.0,350,88,High Protein,Dairy\n"
|
||||
)
|
||||
filename = "sample_nutrition_intelligence_template.csv"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown template type '{tab_type}'. Use stores, analytics, or nutrition.")
|
||||
|
||||
return PlainTextResponse(
|
||||
content=content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from pydantic import BaseModel, Field
|
||||
class ProductOut(BaseModel):
|
||||
image_id: str
|
||||
image_url: Optional[str] = None
|
||||
image_urls: List[str] = Field(default_factory=list)
|
||||
brand: str
|
||||
product_name: str
|
||||
title: Optional[str] = None
|
||||
@@ -31,6 +32,7 @@ class ProductOut(BaseModel):
|
||||
class SourceProductOut(BaseModel):
|
||||
image_id: str
|
||||
image_url: Optional[str] = None
|
||||
image_urls: List[str] = Field(default_factory=list)
|
||||
brand: str
|
||||
product_name: str
|
||||
title: Optional[str] = None
|
||||
|
||||
BIN
backend/app/intelligence/artifacts/demand_forecast_model.joblib
Normal file
BIN
backend/app/intelligence/artifacts/demand_forecast_model.joblib
Normal file
Binary file not shown.
BIN
backend/app/intelligence/artifacts/discount_model.joblib
Normal file
BIN
backend/app/intelligence/artifacts/discount_model.joblib
Normal file
Binary file not shown.
BIN
backend/app/intelligence/artifacts/popularity_model.joblib
Normal file
BIN
backend/app/intelligence/artifacts/popularity_model.joblib
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/app/intelligence/artifacts/trending_model.joblib
Normal file
BIN
backend/app/intelligence/artifacts/trending_model.joblib
Normal file
Binary file not shown.
@@ -25,11 +25,11 @@ from app.services.price_estimator import classify_category, estimate_price, pars
|
||||
# price realization); standard/budget stores are higher-volume.
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_STORES: List[StoreProfile] = [
|
||||
StoreProfile("STORE-A", "Store-A - Anna Nagar", "Chennai", "premium", footfall_index=18),
|
||||
StoreProfile("STORE-A", "Store-A - Gandhipuram", "Coimbatore", "premium", footfall_index=18),
|
||||
StoreProfile("STORE-B", "Store-B - RS Puram", "Coimbatore", "standard", footfall_index=26),
|
||||
StoreProfile("STORE-C", "Store-C - Gandhipuram", "Coimbatore", "budget", footfall_index=34),
|
||||
StoreProfile("STORE-D", "Store-D - Vadapalani", "Chennai", "standard", footfall_index=24),
|
||||
StoreProfile("STORE-E", "Store-E - Peelamedu", "Coimbatore", "budget", footfall_index=30),
|
||||
StoreProfile("STORE-C", "Store-C - Peelamedu", "Coimbatore", "budget", footfall_index=34),
|
||||
StoreProfile("STORE-D", "Store-D - Podanur", "Coimbatore", "standard", footfall_index=24),
|
||||
StoreProfile("STORE-E", "Store-E - Ukkadam", "Coimbatore", "budget", footfall_index=30),
|
||||
]
|
||||
|
||||
# Fraction of the full catalog each store stocks (Feature 1: "every
|
||||
|
||||
@@ -9,14 +9,18 @@ Run with:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.infrastructure.settings import API_CORS_ORIGINS
|
||||
from app.api.routers import health, brands, search, chat, catalog
|
||||
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
|
||||
from app.api.routers import nutrition, nutrition_admin, upload
|
||||
from app.services.store_db import ensure_store_intelligence_schema
|
||||
from app.services.nutrition_db import ensure_nutrition_schema
|
||||
|
||||
@@ -29,31 +33,26 @@ logger = logging.getLogger(__name__)
|
||||
app = FastAPI(
|
||||
title="Brand Product Search Engine - RAG API",
|
||||
description=(
|
||||
"Local, CPU-only RAG API over an Indian FMCG product catalog stored in "
|
||||
"pgvector. Retrieval uses sentence-transformers/all-MiniLM-L6-v2 "
|
||||
"embeddings + pgvector cosine similarity; generation uses a local "
|
||||
"Ollama model (qwen2.5:1.5b by default). Also includes the v3.0 "
|
||||
"Multi-Store Intelligence layer: store-wise pricing/inventory, "
|
||||
"ML-based discounts, trending detection, recommendations, and "
|
||||
"analytics (see /api/admin/store-intelligence for setup). Also "
|
||||
"includes an AI Nutritional Intelligence module: verified "
|
||||
"nutrition facts retrieved from Open Food Facts (never LLM-"
|
||||
"generated), transparent health/nutrition scoring, allergen and "
|
||||
"diet-compatibility classification, ML-based nutritional "
|
||||
"similarity/clustering, and nutrition analytics (see "
|
||||
"/api/admin/nutrition-intelligence for setup)."
|
||||
"Local, CPU-only RAG API over an Indian FMCG product catalog stored in pgvector. "
|
||||
"Includes automated multi-store intelligence, ML discount engines, and nutrition intelligence."
|
||||
),
|
||||
version="3.1.0",
|
||||
version="3.2.0",
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _on_startup() -> None:
|
||||
# Idempotent - safe to call every boot, mirrors how brand tables are
|
||||
# lazily ensured elsewhere in this codebase. Never touches or drops
|
||||
# any existing table.
|
||||
ensure_store_intelligence_schema()
|
||||
ensure_nutrition_schema()
|
||||
# Asynchronously ensure schemas and background auto-init so server starts instantly (<1s)
|
||||
def _async_init():
|
||||
try:
|
||||
ensure_store_intelligence_schema()
|
||||
ensure_nutrition_schema()
|
||||
from app.api.routers.system import _run_background_auto_seed
|
||||
_run_background_auto_seed()
|
||||
except Exception as e:
|
||||
logger.warning("Startup background init warning: %s", e)
|
||||
|
||||
threading.Thread(target=_async_init, daemon=True).start()
|
||||
|
||||
|
||||
app.add_middleware(
|
||||
@@ -65,6 +64,7 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(health.router, prefix="/api")
|
||||
app.include_router(system.router, prefix="/api")
|
||||
app.include_router(brands.router, prefix="/api")
|
||||
app.include_router(search.router, prefix="/api")
|
||||
app.include_router(chat.router, prefix="/api")
|
||||
@@ -77,12 +77,27 @@ app.include_router(recommendations.router, prefix="/api")
|
||||
app.include_router(store_admin.router, prefix="/api")
|
||||
app.include_router(nutrition.router, prefix="/api")
|
||||
app.include_router(nutrition_admin.router, prefix="/api")
|
||||
app.include_router(upload.router, prefix="/api")
|
||||
|
||||
# Serve built frontend static files if dist exists (single-port unified deployment)
|
||||
FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist"
|
||||
if FRONTEND_DIST.exists() and (FRONTEND_DIST / "assets").exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets")
|
||||
|
||||
@app.get("/")
|
||||
def root() -> dict:
|
||||
return {
|
||||
"service": "Brand Product Search Engine - RAG API",
|
||||
"docs": "/docs",
|
||||
"health": "/api/health",
|
||||
}
|
||||
@app.get("/{full_path:path}")
|
||||
def serve_frontend(full_path: str):
|
||||
if full_path.startswith("api") or full_path.startswith("docs") or full_path.startswith("openapi.json"):
|
||||
return None
|
||||
file_path = FRONTEND_DIST / full_path
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
return FileResponse(FRONTEND_DIST / "index.html")
|
||||
else:
|
||||
@app.get("/")
|
||||
def root() -> dict:
|
||||
return {
|
||||
"service": "Brand Product Search Engine - RAG API",
|
||||
"docs": "/docs",
|
||||
"health": "/api/health",
|
||||
"system_status": "/api/system/status",
|
||||
}
|
||||
|
||||
@@ -53,27 +53,27 @@ from typing import Dict, List, Optional, Tuple
|
||||
# this category when sanitizing cross-category language
|
||||
# out of a generated description.
|
||||
CATEGORY_REGISTRY: List[Dict[str, object]] = [
|
||||
{"category": "Biscuits & Cookies", "keywords": ["biscuits", "biscuit", "cookies", "cookie"], "generic_term": "biscuit"},
|
||||
{"category": "Biscuits & Cookies", "keywords": ["biscuits", "biscuit", "biscit", "biskut", "cookies", "cookie"], "generic_term": "biscuit"},
|
||||
{"category": "Rusk", "keywords": ["rusks", "rusk"], "generic_term": "rusk"},
|
||||
{"category": "Crackers", "keywords": ["crackers", "cracker", "saltine"], "generic_term": "cracker"},
|
||||
{"category": "Cakes & Muffins", "keywords": ["cakes", "cake", "muffins", "muffin"], "generic_term": "bakery item"},
|
||||
{"category": "Bakery & Breads", "keywords": ["bread", "buns", "bun", "pav"], "generic_term": "bakery product"},
|
||||
{"category": "Noodles & Instant Food", "keywords": ["noodles", "noodle", "instant food", "vermicelli", "pasta"], "generic_term": "instant food product"},
|
||||
{"category": "Noodles & Instant Food", "keywords": ["noodles", "noodle", "instant food", "vermicelli", "pasta", "maggi"], "generic_term": "instant food product"},
|
||||
{"category": "Candy & Confectionery", "keywords": ["candy", "candies", "toffee", "toffees", "lollipop", "lollipops", "confectionery", "mints", "chewing gum"], "generic_term": "candy"},
|
||||
{"category": "Snacks", "keywords": ["snacks", "snack", "chips", "namkeen", "wafers", "wafer"], "generic_term": "snack"},
|
||||
{"category": "Chocolates", "keywords": ["chocolates", "chocolate", "cocoa"], "generic_term": "chocolate"},
|
||||
{"category": "Cooking Oils", "keywords": ["cooking oil", "edible oil", "sunflower oil", "mustard oil", "vanaspati", "refined oil"], "generic_term": "cooking oil"},
|
||||
{"category": "Atta & Staples", "keywords": ["atta", "wheat flour", "flour", "rice", "dal", "pulses", "staples"], "generic_term": "staple product"},
|
||||
{"category": "Dairy", "keywords": ["milk", "dairy", "cheese", "paneer", "curd", "yogurt", "butter"], "generic_term": "dairy product"},
|
||||
{"category": "Oral Care", "keywords": ["toothpaste", "toothbrush", "mouthwash"], "generic_term": "oral care product"},
|
||||
{"category": "Hair Care", "keywords": ["shampoo", "conditioner", "hair oil"], "generic_term": "hair care product"},
|
||||
{"category": "Bath Soap", "keywords": ["bath soap", "soap bar", "soap"], "generic_term": "soap"},
|
||||
{"category": "Skin & Bath Care", "keywords": ["face wash", "body lotion", "skin cream", "moisturizer", "body wash"], "generic_term": "skin care product"},
|
||||
{"category": "Snacks", "keywords": ["snacks", "snack", "chips", "namkeen", "wafers", "wafer", "kurkure", "lays"], "generic_term": "snack"},
|
||||
{"category": "Chocolates", "keywords": ["chocolates", "chocolate", "chocate", "choclate", "cocoa", "cadbury chocolate", "dairy milk"], "generic_term": "chocolate"},
|
||||
{"category": "Cooking Oils", "keywords": ["cooking oil", "edible oil", "sunflower oil", "mustard oil", "vanaspati", "refined oil", "oil", "oils"], "generic_term": "cooking oil"},
|
||||
{"category": "Atta & Staples", "keywords": ["atta", "wheat flour", "flour", "rice", "dal", "pulses", "staples", "suji", "maida"], "generic_term": "staple product"},
|
||||
{"category": "Dairy", "keywords": ["milk", "dairy", "cheese", "paneer", "panner", "paner", "paneerr", "curd", "yogurt", "butter", "ghee", "dahi"], "generic_term": "dairy product"},
|
||||
{"category": "Oral Care", "keywords": ["toothpaste", "toothbrush", "mouthwash", "paste"], "generic_term": "oral care product"},
|
||||
{"category": "Hair Care", "keywords": ["shampoo", "shampooo", "conditioner", "hair oil"], "generic_term": "hair care product"},
|
||||
{"category": "Bath Soap", "keywords": ["bath soap", "soap bar", "soap", "soaps"], "generic_term": "soap"},
|
||||
{"category": "Skin & Bath Care", "keywords": ["face wash", "body lotion", "skin cream", "moisturizer", "body wash", "cream", "lotion"], "generic_term": "skin care product"},
|
||||
{"category": "Household Cleaning", "keywords": ["detergent", "laundry", "dishwash", "floor cleaner", "handwash", "cleaner"], "generic_term": "cleaning product"},
|
||||
{"category": "Fragrance & Deodorants", "keywords": ["deodorant", "deo spray", "perfume", "fragrance", "body spray"], "generic_term": "fragrance product"},
|
||||
{"category": "Fragrance & Deodorants", "keywords": ["deodorant", "deo spray", "perfume", "fragrance", "body spray", "deo"], "generic_term": "fragrance product"},
|
||||
{"category": "Household - Agarbatti", "keywords": ["agarbatti", "incense sticks", "incense stick"], "generic_term": "agarbatti"},
|
||||
{"category": "Household - Lamp Oil", "keywords": ["lamp oil"], "generic_term": "lamp oil"},
|
||||
{"category": "Health Care - Antiseptic", "keywords": ["antiseptic", "disinfectant liquid"], "generic_term": "antiseptic product"},
|
||||
{"category": "Health Care - Antiseptic", "keywords": ["antiseptic", "disinfectant liquid", "sanitizer"], "generic_term": "antiseptic product"},
|
||||
]
|
||||
|
||||
# All known canonical category names, in priority order.
|
||||
@@ -90,6 +90,14 @@ def _normalize(text: Optional[str]) -> str:
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
QUERY_STOP_WORDS = {
|
||||
"price", "prices", "cost", "costs", "which", "where", "what", "show",
|
||||
"find", "have", "product", "products", "item", "items", "brand", "brands",
|
||||
"under", "below", "less", "more", "many", "total", "count", "please", "today",
|
||||
"recommend", "suggest", "options", "option", "rate", "rates",
|
||||
}
|
||||
|
||||
|
||||
def _find_matches(text: str) -> List[Tuple[str, str, int]]:
|
||||
"""Return (category, matched_keyword, keyword_length) for every keyword
|
||||
found as a whole word/phrase in `text` (case-insensitive)."""
|
||||
@@ -103,6 +111,22 @@ def _find_matches(text: str) -> List[Tuple[str, str, int]]:
|
||||
pattern = r"\b" + re.escape(kw) + r"\b"
|
||||
if re.search(pattern, lower):
|
||||
matches.append((category, kw, len(kw)))
|
||||
|
||||
# Fuzzy matching fallback if exact word search found nothing
|
||||
if not matches:
|
||||
import difflib
|
||||
words = re.findall(r"\b[a-z]{4,}\b", lower)
|
||||
for entry in CATEGORY_REGISTRY:
|
||||
category = entry["category"]
|
||||
for kw in entry["keywords"]:
|
||||
for word in words:
|
||||
if word in QUERY_STOP_WORDS:
|
||||
continue
|
||||
# Check close similarity for words >= 4 chars
|
||||
ratio = difflib.SequenceMatcher(None, word, kw).ratio()
|
||||
if ratio >= 0.8:
|
||||
matches.append((category, kw, len(kw)))
|
||||
break
|
||||
return matches
|
||||
|
||||
|
||||
|
||||
@@ -113,46 +113,136 @@ def _convert(value: Optional[float], kind: str) -> Optional[float]:
|
||||
return round(v, 3)
|
||||
|
||||
|
||||
import re
|
||||
|
||||
def _clean_title_for_search(title: str) -> str:
|
||||
"""Strips size, volume, weight, and packaging suffixes to improve search accuracy."""
|
||||
t = re.sub(r'\b\d+(\.\d+)?\s*(g|kg|ml|l|gm|ltr|grm|pack|pc|pcs)\b', '', title, flags=re.IGNORECASE)
|
||||
t = re.sub(r'[-_]', ' ', t)
|
||||
return ' '.join(t.split())
|
||||
|
||||
|
||||
def _looks_non_food(title: str, category: str) -> bool:
|
||||
text = f"{title} {category}".lower()
|
||||
return any(kw in text for kw in NON_FOOD_KEYWORDS)
|
||||
|
||||
|
||||
def _search_openfoodfacts(query: str, max_results: int = 5) -> List[dict]:
|
||||
if not query.strip():
|
||||
def _search_openfoodfacts(query: str, brand: str = "", category: str = "", max_results: int = 5) -> List[dict]:
|
||||
if not query.strip() and not brand.strip():
|
||||
return []
|
||||
|
||||
fields_str = (
|
||||
"code,product_name,brands,nutriments,serving_size,serving_quantity,"
|
||||
"allergens_tags,labels_tags,ingredients_analysis_tags,categories_tags,"
|
||||
"ingredients_text,nutriscore_grade,nutrition_data_per,quantity"
|
||||
)
|
||||
headers = {"User-Agent": _BROWSER_UA}
|
||||
|
||||
# Strategy 1: OFF v2 API search with brands_tags + search_terms
|
||||
if brand.strip():
|
||||
cleaned_query = _clean_title_for_search(query)
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"https://{OFF_HOST}/api/v2/search",
|
||||
params={
|
||||
"brands_tags": brand.lower().strip(),
|
||||
"search_terms": cleaned_query,
|
||||
"page_size": max_results,
|
||||
"fields": fields_str,
|
||||
},
|
||||
headers=headers,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
prods = resp.json().get("products", []) or []
|
||||
if prods:
|
||||
return prods
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug(f"OFF v2 search Strategy 1 failed for '{brand}' '{query}': {e}")
|
||||
|
||||
# Strategy 2: OFF v2 API search using search_terms with combined brand + query
|
||||
full_term = f"{brand} {_clean_title_for_search(query)}".strip()
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"https://{OFF_HOST}/cgi/search.pl",
|
||||
f"https://{OFF_HOST}/api/v2/search",
|
||||
params={
|
||||
"search_terms": query,
|
||||
"search_simple": 1,
|
||||
"action": "process",
|
||||
"json": 1,
|
||||
"search_terms": full_term,
|
||||
"page_size": max_results,
|
||||
"fields": (
|
||||
"code,product_name,brands,nutriments,serving_size,serving_quantity,"
|
||||
"allergens_tags,labels_tags,ingredients_analysis_tags,categories_tags,"
|
||||
"ingredients_text,nutriscore_grade,nutrition_data_per,quantity"
|
||||
),
|
||||
"fields": fields_str,
|
||||
},
|
||||
headers={"User-Agent": _BROWSER_UA},
|
||||
headers=headers,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
return resp.json().get("products", []) or []
|
||||
if resp.status_code == 200:
|
||||
prods = resp.json().get("products", []) or []
|
||||
if prods:
|
||||
return prods
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug(f"Open Food Facts lookup failed for '{query}': {e}")
|
||||
return []
|
||||
logger.debug(f"OFF v2 search Strategy 2 failed for '{full_term}': {e}")
|
||||
|
||||
# Strategy 3: OFF v2 API search using q parameter
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"https://{OFF_HOST}/api/v2/search",
|
||||
params={
|
||||
"q": full_term,
|
||||
"page_size": max_results,
|
||||
"fields": fields_str,
|
||||
},
|
||||
headers=headers,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
prods = resp.json().get("products", []) or []
|
||||
if prods:
|
||||
return prods
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug(f"OFF v2 search Strategy 3 failed for '{full_term}': {e}")
|
||||
|
||||
# Strategy 4: Fallback to category + brand search
|
||||
if category.strip() and brand.strip():
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"https://{OFF_HOST}/api/v2/search",
|
||||
params={
|
||||
"search_terms": f"{brand} {category}".strip(),
|
||||
"page_size": max_results,
|
||||
"fields": fields_str,
|
||||
},
|
||||
headers=headers,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("products", []) or []
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug(f"OFF v2 search Strategy 4 failed: {e}")
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _match_confidence(query_brand: str, query_title: str, candidate: dict) -> float:
|
||||
cand_name = f"{candidate.get('brands', '')} {candidate.get('product_name', '')}".lower().strip()
|
||||
query = f"{query_brand} {query_title}".lower().strip()
|
||||
if not cand_name or not query:
|
||||
cand_brand = (candidate.get("brands") or "").lower().strip()
|
||||
cand_name = (candidate.get("product_name") or "").lower().strip()
|
||||
q_brand = query_brand.lower().strip()
|
||||
q_title_clean = _clean_title_for_search(query_title).lower().strip()
|
||||
|
||||
full_cand = f"{cand_brand} {cand_name}".strip()
|
||||
full_query = f"{q_brand} {q_title_clean}".strip()
|
||||
if not full_cand or not full_query:
|
||||
return 0.0
|
||||
return SequenceMatcher(None, query, cand_name).ratio()
|
||||
|
||||
ratio = SequenceMatcher(None, full_query, full_cand).ratio()
|
||||
if q_brand and (q_brand in cand_brand or q_brand in cand_name):
|
||||
ratio = max(ratio, 0.45)
|
||||
|
||||
q_tokens = set(q_title_clean.split())
|
||||
cand_tokens = set(re.sub(r'[-_]', ' ', cand_name).split())
|
||||
if q_tokens and cand_tokens:
|
||||
overlap = len(q_tokens.intersection(cand_tokens)) / float(len(q_tokens))
|
||||
if overlap >= 0.5:
|
||||
ratio = max(ratio, 0.5 + overlap * 0.3)
|
||||
|
||||
return min(1.0, ratio)
|
||||
|
||||
|
||||
def _extract_allergens(candidate: dict) -> List[str]:
|
||||
@@ -219,9 +309,9 @@ def fetch_verified_nutrition(brand: str, title: str, category: str = "") -> Dict
|
||||
if _looks_non_food(title, category):
|
||||
return unavailable
|
||||
|
||||
candidates = _search_openfoodfacts(f"{brand} {title}".strip())
|
||||
candidates = _search_openfoodfacts(query=title, brand=brand, category=category)
|
||||
if not candidates:
|
||||
candidates = _search_openfoodfacts(title)
|
||||
candidates = _search_openfoodfacts(query=title)
|
||||
if not candidates:
|
||||
return unavailable
|
||||
|
||||
|
||||
@@ -135,33 +135,39 @@ RAG_SYSTEM_PROMPT = (
|
||||
"low fat, spicy), only claim a product has that attribute if the CONTEXT text for that product "
|
||||
"actually says so. If none of the retrieved products explicitly confirm the attribute, say you "
|
||||
"couldn't confirm it from the catalog data rather than assuming or guessing.\n"
|
||||
"8. COUNT AND METRICS: If CATALOG METRIC DATA is present in the CONTEXT, state those exact count numbers clearly when answering count or list questions.\n"
|
||||
"Do not mention the word 'CONTEXT' or that you were given a context block; just answer naturally."
|
||||
)
|
||||
|
||||
|
||||
def _build_grounded_fallback_answer(query: str, context_block: str, requested_category: Optional[str] = None) -> str:
|
||||
"""Intelligent non-LLM synthesis of retrieved catalog items when Ollama is offline."""
|
||||
if not context_block or "(No matching products were found" in context_block:
|
||||
cat_str = f" in category **{requested_category}**" if requested_category else ""
|
||||
return f"No matching products were found in the catalog database for your query{cat_str}."
|
||||
|
||||
cat_str = f" ({requested_category})" if requested_category else ""
|
||||
lines = [f"Here are the top catalog recommendations for **'{query}'**{cat_str}:\n"]
|
||||
|
||||
raw_items = [b.strip() for b in context_block.split("\n\n") if b.strip()]
|
||||
for item in raw_items:
|
||||
if item.startswith("CATALOG METRIC DATA:"):
|
||||
lines.append(f"📊 **Catalog Info**: {item.replace('CATALOG METRIC DATA:', '').strip()}\n")
|
||||
else:
|
||||
lines.append(item)
|
||||
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def generate_rag_answer(
|
||||
query: str,
|
||||
context_block: str,
|
||||
history: Optional[List[Dict[str, str]]] = None,
|
||||
requested_category: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Generate a grounded answer for the RAG chat endpoint.
|
||||
|
||||
`context_block` is a pre-formatted block of retrieved products (built by
|
||||
`app.services.rag_service.build_context`). `history` is an optional list
|
||||
of prior `{"role": "user"|"assistant", "content": ...}` turns so the
|
||||
model can handle short follow-up questions ("what about a cheaper one?").
|
||||
`requested_category` is the product category auto-detected from the
|
||||
query (see `category_registry.detect_category_from_text`), passed
|
||||
through so the model has an explicit reminder of what was searched for
|
||||
even if the CONTEXT ends up empty.
|
||||
"""
|
||||
"""Generate a grounded answer for the RAG chat endpoint."""
|
||||
if not _ensure_client():
|
||||
return (
|
||||
"I can't reach the local Ollama server right now, so I can't generate an "
|
||||
"answer. Please confirm Ollama is running (`ollama serve`) and that the "
|
||||
f"'{OLLAMA_MODEL_NAME}' model is pulled (`ollama pull {OLLAMA_MODEL_NAME}`)."
|
||||
)
|
||||
return _build_grounded_fallback_answer(query, context_block, requested_category)
|
||||
|
||||
history_block = ""
|
||||
if history:
|
||||
@@ -188,11 +194,7 @@ def generate_rag_answer(
|
||||
|
||||
answer = _generate(RAG_SYSTEM_PROMPT, user_prompt, max_retries=2)
|
||||
if not answer:
|
||||
return (
|
||||
"I wasn't able to generate a response from the local model just now. "
|
||||
"This can happen if the model is still loading or the machine is low on "
|
||||
"free RAM - please try again in a few seconds."
|
||||
)
|
||||
return _build_grounded_fallback_answer(query, context_block, requested_category)
|
||||
return answer.strip()
|
||||
|
||||
|
||||
|
||||
@@ -94,6 +94,109 @@ def product_matches_attribute(product_text: str, attribute: str, value: str) ->
|
||||
return any(hint in lower for hint in hints)
|
||||
|
||||
|
||||
def is_count_query(query: str) -> bool:
|
||||
"""Return True if the query asks for a total/count of products or brands."""
|
||||
if not query:
|
||||
return False
|
||||
lower = query.lower()
|
||||
patterns = [
|
||||
r"how many",
|
||||
r"count of",
|
||||
r"total (?:number of )?(?:products|items|brands)",
|
||||
r"number of products",
|
||||
r"how many products",
|
||||
r"how many items",
|
||||
r"how many brands",
|
||||
r"total count",
|
||||
]
|
||||
return any(re.search(p, lower) for p in patterns)
|
||||
|
||||
|
||||
def extract_max_price(query: str) -> Optional[float]:
|
||||
"""Extract a numeric maximum price ceiling from a query if present.
|
||||
e.g. 'under ₹150' -> 150.0, 'less than 100 rupees' -> 100.0
|
||||
"""
|
||||
if not query:
|
||||
return None
|
||||
lower = query.lower().replace(",", "")
|
||||
patterns = [
|
||||
r"(?:under|below|less than|within|upto|up to|budget of|max(?:imum)?)\s*(?:₹|rs\.?|inr)?\s*(\d+(?:\.\d+)?)",
|
||||
r"(?:₹|rs\.?|inr)\s*(\d+(?:\.\d+)?)\s*(?:or less|max|under|below)",
|
||||
r"<\s*(?:₹|rs\.?|inr)?\s*(\d+(?:\.\d+)?)",
|
||||
]
|
||||
for p in patterns:
|
||||
match = re.search(p, lower)
|
||||
if match:
|
||||
try:
|
||||
val = float(match.group(1))
|
||||
if val > 0:
|
||||
return val
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
KNOWN_BRANDS = [
|
||||
"Amul", "Cadbury", "Cavinkare", "Coca-Cola", "Colgate-Palmolive", "Dabur",
|
||||
"Godrej", "Grb", "Hindustan Unilever", "Lion Dates", "Manna", "Milky Mist",
|
||||
"Naga", "Nestle", "P&G", "Pepsico"
|
||||
]
|
||||
|
||||
BRAND_SEARCH_MAP = {
|
||||
"cadbury": "Cadbury",
|
||||
"amul": "Amul",
|
||||
"cavinkare": "Cavinkare",
|
||||
"coca-cola": "Coca-Cola",
|
||||
"coca cola": "Coca-Cola",
|
||||
"coke": "Coca-Cola",
|
||||
"colgate-palmolive": "Colgate-Palmolive",
|
||||
"colgate": "Colgate-Palmolive",
|
||||
"dabur": "Dabur",
|
||||
"godrej": "Godrej",
|
||||
"grb": "Grb",
|
||||
"hindustan unilever": "Hindustan Unilever",
|
||||
"hul": "Hindustan Unilever",
|
||||
"unilever": "Hindustan Unilever",
|
||||
"lion dates": "Lion Dates",
|
||||
"lion": "Lion Dates",
|
||||
"manna": "Manna",
|
||||
"milky mist": "Milky Mist",
|
||||
"naga": "Naga",
|
||||
"nestle": "Nestle",
|
||||
"p&g": "P&G",
|
||||
"pg": "P&G",
|
||||
"pepsico": "Pepsico",
|
||||
"pepsi": "Pepsico",
|
||||
}
|
||||
|
||||
|
||||
def extract_brand_mention(query: str) -> Optional[str]:
|
||||
"""Detect if a brand name is explicitly mentioned in the query text."""
|
||||
if not query:
|
||||
return None
|
||||
from app.services.brand_registry import BRAND_ALIASES, resolve_parent_brand
|
||||
lower = query.lower()
|
||||
|
||||
# 1. Check direct search map
|
||||
for alias in sorted(BRAND_SEARCH_MAP.keys(), key=len, reverse=True):
|
||||
pattern = r"\b" + re.escape(alias) + r"\b"
|
||||
if re.search(pattern, lower):
|
||||
return BRAND_SEARCH_MAP[alias]
|
||||
|
||||
# 2. Check sub-brand aliases (e.g. "oreo", "maggi", "good day")
|
||||
sorted_aliases = sorted(BRAND_ALIASES.keys(), key=len, reverse=True)
|
||||
for alias in sorted_aliases:
|
||||
pattern = r"\b" + re.escape(alias) + r"\b"
|
||||
if re.search(pattern, lower):
|
||||
parent = resolve_parent_brand(alias)
|
||||
# Normalize to canonical known brand name case
|
||||
for kb in KNOWN_BRANDS:
|
||||
if kb.lower() == parent.lower():
|
||||
return kb
|
||||
return parent.title()
|
||||
return None
|
||||
|
||||
|
||||
def detected_category(query: str) -> Optional[str]:
|
||||
"""Thin wrapper kept for readability at call sites in rag_service."""
|
||||
return detect_category_from_text(query)
|
||||
|
||||
@@ -32,9 +32,22 @@ from app.infrastructure.settings import (
|
||||
from app.services.category_registry import category_matches, detect_category_from_text
|
||||
from app.services.embeddings_service import embed_texts
|
||||
from app.services.ollama_service import generate_rag_answer
|
||||
from app.services.query_intent import extract_attributes, product_matches_attribute
|
||||
from app.services.query_intent import (
|
||||
extract_attributes,
|
||||
extract_brand_mention,
|
||||
extract_max_price,
|
||||
is_count_query,
|
||||
product_matches_attribute,
|
||||
)
|
||||
from app.services.s3_service import s3_service
|
||||
from app.services.vector_store import semantic_search, text_search
|
||||
from app.services.vector_store import (
|
||||
count_products_all_brands,
|
||||
count_products_by_brand,
|
||||
list_available_brands,
|
||||
list_categories_for_brand,
|
||||
semantic_search,
|
||||
text_search,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,6 +56,7 @@ logger = logging.getLogger(__name__)
|
||||
class RetrievedProduct:
|
||||
image_id: str
|
||||
image_url: str = ""
|
||||
image_urls: List[str] = field(default_factory=list)
|
||||
brand: str = ""
|
||||
title: str = ""
|
||||
product_name: Optional[str] = None
|
||||
@@ -67,6 +81,7 @@ class RetrievedProduct:
|
||||
return {
|
||||
"image_id": self.image_id,
|
||||
"image_url": self.image_url,
|
||||
"image_urls": self.image_urls,
|
||||
"brand": self.brand,
|
||||
"title": self.title,
|
||||
"product_name": self.product_name,
|
||||
@@ -102,12 +117,34 @@ class RagAnswer:
|
||||
}
|
||||
|
||||
|
||||
def _clean_url(url: Optional[str]) -> Optional[str]:
|
||||
if not url:
|
||||
return None
|
||||
return str(url).replace('{width}', '800')
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
return RetrievedProduct(
|
||||
image_id=image_id,
|
||||
image_url=s3_service.get_product_image_url(brand, image_id) or "",
|
||||
image_url=primary_url,
|
||||
image_urls=final_urls,
|
||||
brand=brand,
|
||||
product_name=row.get("product_name") or row.get("title") or "Unknown product",
|
||||
title=row.get("title") or row.get("product_name") or None,
|
||||
@@ -126,14 +163,6 @@ def _row_to_retrieved_product(row: Dict[str, Any]) -> RetrievedProduct:
|
||||
|
||||
|
||||
def _filter_to_category(rows: List[Dict[str, Any]], target_category: Optional[str]) -> List[Dict[str, Any]]:
|
||||
"""Drop any row whose stored category doesn't match `target_category`.
|
||||
|
||||
This is the safety net against the "biscuit query returns snacks" bug:
|
||||
even if a product scores well on raw embedding similarity (e.g.
|
||||
because its LLM-generated description loosely used the word
|
||||
"biscuit"), it's excluded here unless its own `category` column
|
||||
genuinely matches what the user asked for.
|
||||
"""
|
||||
if not target_category:
|
||||
return rows
|
||||
return [r for r in rows if category_matches(r.get("category"), target_category)]
|
||||
@@ -141,10 +170,7 @@ def _filter_to_category(rows: List[Dict[str, Any]], target_category: Optional[st
|
||||
|
||||
def _rerank_by_attributes(products: List[RetrievedProduct], attrs: Dict[str, str]) -> List[RetrievedProduct]:
|
||||
"""Move products with explicit evidence of a requested attribute (e.g.
|
||||
"sugar": "low") to the front, without dropping any product - most
|
||||
catalog entries won't mention the attribute either way, and we'd
|
||||
rather show them (ranked below confirmed matches) than show nothing.
|
||||
Original similarity ordering is preserved as the tie-breaker.
|
||||
"sugar": "low") to the front, without dropping any product.
|
||||
"""
|
||||
def evidence_count(p: RetrievedProduct) -> int:
|
||||
text = " ".join(filter(None, [p.description or "", " ".join(p.highlights), " ".join(p.nutrients)]))
|
||||
@@ -160,29 +186,13 @@ def _rerank_by_attributes(products: List[RetrievedProduct], attrs: Dict[str, str
|
||||
|
||||
def retrieve(query: str, brand: Optional[str] = None, top_k: Optional[int] = None,
|
||||
category: Optional[str] = None, max_distance: Optional[float] = None) -> List[RetrievedProduct]:
|
||||
"""Embed `query` and run a pgvector similarity search. Used by both
|
||||
the plain semantic-search endpoint and the RAG chat endpoint.
|
||||
|
||||
Category handling: an explicit `category` argument (e.g. a UI filter)
|
||||
always wins. Otherwise the product category the user is asking about
|
||||
is auto-detected from the query text (`detect_category_from_text`) and
|
||||
used to scope the search - both by pushing the filter down into the
|
||||
SQL query (so the right products are actually retrieved, not just
|
||||
ranked, within the top-k) and by re-checking every returned row
|
||||
afterwards. If a category is detected but nothing matches it, the
|
||||
result is an empty list rather than silently widening to other
|
||||
categories - the caller (and ultimately the LLM, per its system
|
||||
prompt) is expected to say plainly that nothing matched rather than
|
||||
substitute a different kind of product.
|
||||
|
||||
Falls back to a text-based ILIKE search when the embedding model
|
||||
fails or the vector search returns no results, so the search box
|
||||
still returns matching products when pgvector embeddings are
|
||||
unavailable or stale.
|
||||
"""
|
||||
"""Embed `query` and run a pgvector similarity search with category, brand, and price filtering."""
|
||||
top_k = min(top_k or RAG_DEFAULT_TOP_K, RAG_MAX_TOP_K)
|
||||
effective_max_distance = max_distance if max_distance is not None else RAG_MAX_DISTANCE
|
||||
|
||||
target_brand = brand or extract_brand_mention(query)
|
||||
target_category = category or detect_category_from_text(query)
|
||||
max_price = extract_max_price(query)
|
||||
|
||||
try:
|
||||
vectors = embed_texts([query])
|
||||
@@ -194,21 +204,21 @@ def retrieve(query: str, brand: Optional[str] = None, top_k: Optional[int] = Non
|
||||
if vectors:
|
||||
rows = semantic_search(
|
||||
query_embedding=vectors[0],
|
||||
brand=brand,
|
||||
brand=target_brand,
|
||||
top_k=top_k,
|
||||
category=target_category,
|
||||
max_distance=effective_max_distance,
|
||||
max_price=max_price,
|
||||
)
|
||||
rows = _filter_to_category(rows, target_category)
|
||||
if not rows:
|
||||
logger.info(
|
||||
"Semantic search returned no in-category results for %r (category=%r), "
|
||||
"falling back to text search",
|
||||
"Semantic search returned no in-category results for %r (category=%r), falling back to text search",
|
||||
query, target_category,
|
||||
)
|
||||
|
||||
if not rows:
|
||||
rows = text_search(query, brand=brand, top_k=top_k, category=target_category)
|
||||
rows = text_search(query, brand=target_brand, top_k=top_k, category=target_category, max_price=max_price)
|
||||
rows = _filter_to_category(rows, target_category)
|
||||
|
||||
products = [_row_to_retrieved_product(r) for r in rows]
|
||||
@@ -220,16 +230,13 @@ def retrieve(query: str, brand: Optional[str] = None, top_k: Optional[int] = Non
|
||||
return products
|
||||
|
||||
|
||||
def build_context(products: List[RetrievedProduct], max_chars: int = RAG_MAX_CONTEXT_CHARS) -> str:
|
||||
"""Format retrieved products into a compact text block for the LLM prompt.
|
||||
def build_context(products: List[RetrievedProduct], max_chars: int = RAG_MAX_CONTEXT_CHARS,
|
||||
metric_info: Optional[str] = None) -> str:
|
||||
"""Format retrieved products and optional metric info into a compact text block for the LLM prompt."""
|
||||
prefix = f"{metric_info}\n\n" if metric_info else ""
|
||||
|
||||
Stays under `max_chars` so a tiny 1.5B model on CPU doesn't choke on an
|
||||
oversized prompt - we truncate per-product description rather than
|
||||
dropping whole products, so the model still sees the full breadth of
|
||||
matches.
|
||||
"""
|
||||
if not products:
|
||||
return "(No matching products were found in the catalog for this query.)"
|
||||
return prefix + "(No matching products were found in the catalog for this query.)"
|
||||
|
||||
per_item_budget = max(200, max_chars // max(1, len(products)))
|
||||
lines = []
|
||||
@@ -246,11 +253,13 @@ def build_context(products: List[RetrievedProduct], max_chars: int = RAG_MAX_CON
|
||||
parts.append(f" Sizes: {', '.join(p.size_variants[:6])}")
|
||||
if p.highlights:
|
||||
parts.append(f" Highlights: {', '.join(p.highlights[:5])}")
|
||||
if p.nutrients:
|
||||
parts.append(f" Nutrients: {', '.join(p.nutrients[:5])}")
|
||||
if desc:
|
||||
parts.append(f" Description: {desc}")
|
||||
lines.append("\n".join(parts))
|
||||
|
||||
return "\n\n".join(lines)
|
||||
return prefix + "\n\n".join(lines)
|
||||
|
||||
|
||||
def answer_query(
|
||||
@@ -260,12 +269,33 @@ def answer_query(
|
||||
category: Optional[str] = None,
|
||||
history: Optional[List[Dict[str, str]]] = None,
|
||||
) -> RagAnswer:
|
||||
"""End-to-end RAG: retrieve relevant products, then generate a grounded answer."""
|
||||
"""End-to-end RAG: retrieve relevant products, compute exact counts if requested, then generate a grounded answer."""
|
||||
target_brand = brand or extract_brand_mention(query)
|
||||
target_category = category or detect_category_from_text(query)
|
||||
metric_info: Optional[str] = None
|
||||
|
||||
if is_count_query(query):
|
||||
if target_brand:
|
||||
cnt = count_products_by_brand(target_brand, category=target_category)
|
||||
cats = list_categories_for_brand(target_brand)
|
||||
cat_str = f" in category '{target_category}'" if target_category else ""
|
||||
metric_info = (
|
||||
f"CATALOG METRIC DATA: Exact product count for brand '{target_brand}'{cat_str} in the database is {cnt}. "
|
||||
f"Categories available under {target_brand}: {', '.join(cats)}."
|
||||
)
|
||||
elif target_category:
|
||||
cnt = count_products_all_brands(category=target_category)
|
||||
metric_info = f"CATALOG METRIC DATA: Total products matching category '{target_category}' across all brands in database is {cnt}."
|
||||
else:
|
||||
total = count_products_all_brands()
|
||||
brands = list_available_brands()
|
||||
metric_info = f"CATALOG METRIC DATA: Total catalog items across all brands is {total}. Known brands: {', '.join(brands)}."
|
||||
|
||||
products = retrieve(query, brand=brand, top_k=top_k, category=category)
|
||||
context = build_context(products)
|
||||
context = build_context(products, metric_info=metric_info)
|
||||
answer_text = generate_rag_answer(query, context, history=history, requested_category=target_category)
|
||||
|
||||
return RagAnswer(
|
||||
answer=answer_text, sources=products, query=query, brand=brand,
|
||||
answer=answer_text, sources=products, query=query, brand=target_brand or brand,
|
||||
detected_category=target_category,
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ class S3Service:
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = USE_S3 and all([S3_ACCESS_KEY, S3_SECRET_KEY, S3_ENDPOINT, S3_BUCKET])
|
||||
self._url_cache = {}
|
||||
if self.enabled:
|
||||
# Use regional base endpoint for API calls to avoid NoSuchKey errors with virtual-hosted style URLs
|
||||
# DO Spaces API endpoint format: https://{region}.digitaloceanspaces.com
|
||||
@@ -231,15 +232,12 @@ class S3Service:
|
||||
return f"{S3_ENDPOINT}/{key}"
|
||||
|
||||
def get_product_image_url(self, brand: str, image_id: str) -> str:
|
||||
"""Construct the first image URL for a product using the known S3 naming convention.
|
||||
|
||||
Images are uploaded as ``image_000.jpg``, ``image_001.jpg`` etc.
|
||||
under ``daily/brands/{brand}/{image_id}/``, so we can build the
|
||||
URL directly without a slow S3 ``list_objects`` call. Returns an
|
||||
empty string when the image is unavailable or S3 is disabled.
|
||||
"""
|
||||
"""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"
|
||||
return self.get_public_url(key)
|
||||
@@ -249,6 +247,10 @@ class S3Service:
|
||||
if not self.enabled or not image_id:
|
||||
return []
|
||||
|
||||
cache_key = f"{brand}:{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
|
||||
@@ -278,9 +280,12 @@ class S3Service:
|
||||
|
||||
if image_urls:
|
||||
logger.info(f"✅ Found {len(image_urls)} images under prefix: {prefix}")
|
||||
return sorted(image_urls)
|
||||
res = sorted(image_urls)
|
||||
self._url_cache[cache_key] = res
|
||||
return res
|
||||
|
||||
logger.debug("No images found in S3 for %s across searched prefixes", image_id)
|
||||
self._url_cache[cache_key] = []
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -39,6 +39,8 @@ def get_brand_table_ddl(brand: str) -> str:
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
image_id TEXT UNIQUE NOT NULL,
|
||||
image_url TEXT,
|
||||
image_urls TEXT[],
|
||||
|
||||
-- Essential pricing fields
|
||||
price_range TEXT,
|
||||
@@ -96,6 +98,8 @@ def _ensure_columns(cur, table_name: str) -> None:
|
||||
"description": "TEXT",
|
||||
"category": "TEXT",
|
||||
"image_id": "TEXT",
|
||||
"image_url": "TEXT",
|
||||
"image_urls": "TEXT[]",
|
||||
"price_range": "TEXT",
|
||||
"size_variants": "TEXT[]",
|
||||
"providers": "TEXT[]",
|
||||
@@ -123,7 +127,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", "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", "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")
|
||||
@@ -140,9 +144,6 @@ def _ensure_columns(cur, table_name: str) -> None:
|
||||
logger.warning(f"Unique index creation on {table_name}.image_id: {e}")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def ensure_brand_schema(brand: str) -> str:
|
||||
"""Ensure brand-specific table exists and return table name"""
|
||||
conn = _connect()
|
||||
@@ -191,6 +192,20 @@ def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: b
|
||||
category = p.get("category") or "Uncategorized"
|
||||
image_id = p.get("image_id") or ""
|
||||
|
||||
image_url = p.get("image_url") or p.get("primary_image") or ""
|
||||
if isinstance(image_url, str):
|
||||
image_url = image_url.replace('{width}', '800')
|
||||
else:
|
||||
image_url = ""
|
||||
|
||||
raw_image_urls = p.get("image_urls") or []
|
||||
if isinstance(raw_image_urls, list):
|
||||
image_urls = [str(u).replace('{width}', '800') for u in raw_image_urls if u]
|
||||
else:
|
||||
image_urls = []
|
||||
if not image_urls and image_url:
|
||||
image_urls = [image_url]
|
||||
|
||||
# Essential pricing fields
|
||||
price_range = p.get("price_range") or ""
|
||||
size_variants = p.get("size_variants", [])
|
||||
@@ -244,6 +259,8 @@ def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: b
|
||||
description,
|
||||
category,
|
||||
image_id,
|
||||
image_url,
|
||||
image_urls,
|
||||
price_range,
|
||||
size_variants, # TEXT[] - psycopg will handle conversion
|
||||
providers, # TEXT[] - psycopg will handle conversion
|
||||
@@ -262,14 +279,16 @@ def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: b
|
||||
cur.executemany(
|
||||
f"""
|
||||
INSERT INTO {table_name}
|
||||
(product_name, title, description, category, image_id, price_range, size_variants, providers,
|
||||
(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)
|
||||
VALUES (%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,
|
||||
description = EXCLUDED.description,
|
||||
category = EXCLUDED.category,
|
||||
image_url = EXCLUDED.image_url,
|
||||
image_urls = EXCLUDED.image_urls,
|
||||
price_range = EXCLUDED.price_range,
|
||||
size_variants = EXCLUDED.size_variants,
|
||||
providers = EXCLUDED.providers,
|
||||
@@ -526,12 +545,27 @@ def list_categories_for_brand(brand: str) -> List[str]:
|
||||
conn.close()
|
||||
|
||||
|
||||
def parse_price_range(price_range_str: Optional[str]) -> Tuple[Optional[float], Optional[float]]:
|
||||
"""Parse string representations of prices into numeric floats (min_price, max_price).
|
||||
e.g. '₹120 - ₹150' -> (120.0, 150.0), '₹140' -> (140.0, 140.0)
|
||||
"""
|
||||
if not price_range_str:
|
||||
return None, None
|
||||
nums = [float(n) for n in re.findall(r"\d+(?:\.\d+)?", str(price_range_str).replace(",", ""))]
|
||||
if not nums:
|
||||
return None, None
|
||||
if len(nums) == 1:
|
||||
return nums[0], nums[0]
|
||||
return min(nums), max(nums)
|
||||
|
||||
|
||||
def semantic_search(
|
||||
query_embedding: List[float],
|
||||
brand: Optional[str] = None,
|
||||
top_k: int = 5,
|
||||
category: Optional[str] = None,
|
||||
max_distance: Optional[float] = None,
|
||||
max_price: Optional[float] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Core RAG retrieval step: cosine-similarity search over pgvector.
|
||||
|
||||
@@ -564,6 +598,9 @@ def semantic_search(
|
||||
else:
|
||||
tables = [(name, f"brand_{name}") for name in _list_brand_table_suffixes(cur)]
|
||||
|
||||
# Fetch extra candidates per table if max_price or filtering is applied
|
||||
fetch_limit = top_k * 5 if (max_price is not None or category) else top_k
|
||||
|
||||
for brand_label, table_name in tables:
|
||||
if not _table_exists(cur, table_name):
|
||||
continue
|
||||
@@ -576,7 +613,7 @@ def semantic_search(
|
||||
sql += " AND category ILIKE %s"
|
||||
params.append(f"%{category}%")
|
||||
sql += " ORDER BY distance ASC LIMIT %s"
|
||||
params.append(top_k)
|
||||
params.append(fetch_limit)
|
||||
|
||||
try:
|
||||
cur.execute(sql, params)
|
||||
@@ -588,8 +625,16 @@ def semantic_search(
|
||||
for row in cur.fetchall():
|
||||
record = dict(zip(colnames, row))
|
||||
record["brand"] = record.get("brand") or brand_label
|
||||
if max_distance is None or record["distance"] <= max_distance:
|
||||
results.append(record)
|
||||
|
||||
if max_distance is not None and record["distance"] > max_distance:
|
||||
continue
|
||||
|
||||
if max_price is not None:
|
||||
min_p, max_p = parse_price_range(record.get("price_range"))
|
||||
if min_p is not None and min_p > max_price:
|
||||
continue
|
||||
|
||||
results.append(record)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -603,6 +648,7 @@ def text_search(
|
||||
brand: Optional[str] = None,
|
||||
top_k: int = 10,
|
||||
category: Optional[str] = None,
|
||||
max_price: Optional[float] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fallback text-based search using ILIKE on title and description.
|
||||
|
||||
@@ -617,6 +663,7 @@ def text_search(
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
like_pattern = f"%{query}%"
|
||||
fetch_limit = top_k * 5 if (max_price is not None or category) else top_k
|
||||
|
||||
try:
|
||||
with conn, conn.cursor() as cur:
|
||||
@@ -642,7 +689,7 @@ def text_search(
|
||||
if where_clauses:
|
||||
sql += " WHERE " + " AND ".join(where_clauses)
|
||||
sql += " ORDER BY updated_at DESC LIMIT %s"
|
||||
params.append(top_k)
|
||||
params.append(fetch_limit)
|
||||
|
||||
try:
|
||||
cur.execute(sql, params)
|
||||
@@ -655,6 +702,12 @@ def text_search(
|
||||
record = dict(zip(colnames, row))
|
||||
record["brand"] = brand_label
|
||||
record["distance"] = 1.0
|
||||
|
||||
if max_price is not None:
|
||||
min_p, max_p = parse_price_range(record.get("price_range"))
|
||||
if min_p is not None and min_p > max_price:
|
||||
continue
|
||||
|
||||
results.append(record)
|
||||
|
||||
finally:
|
||||
|
||||
8
backend/scripts/check_stores.py
Normal file
8
backend/scripts/check_stores.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
from app.services.store_db import list_stores
|
||||
|
||||
stores = list_stores()
|
||||
print("Stores in DB:")
|
||||
for s in stores:
|
||||
print(f" - {s['store_id']}: {s['store_name']} ({s.get('city')}) [{s.get('tier')}]")
|
||||
34
backend/scripts/fast_test.py
Normal file
34
backend/scripts/fast_test.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, ".")
|
||||
from app.services.rag_service import retrieve, answer_query
|
||||
from app.services.query_intent import extract_max_price, extract_brand_mention, is_count_query, detected_category
|
||||
|
||||
print("=== Q1: How many products are there in Cadbury? ===")
|
||||
print(" Is Count:", is_count_query("How many products are there in Cadbury?"))
|
||||
print(" Brand Mention:", extract_brand_mention("How many products are there in Cadbury?"))
|
||||
print(" Category:", detected_category("How many products are there in Cadbury?"))
|
||||
|
||||
print("\n=== Q2: Suggest panner less than ₹100 ===")
|
||||
print(" Is Count:", is_count_query("Suggest panner less than ₹100"))
|
||||
print(" Brand Mention:", extract_brand_mention("Suggest panner less than ₹100"))
|
||||
print(" Category:", detected_category("Suggest panner less than ₹100"))
|
||||
print(" Max Price:", extract_max_price("Suggest panner less than ₹100"))
|
||||
q2_prods = retrieve("Suggest panner less than ₹100")
|
||||
for p in q2_prods:
|
||||
print(f" - [{p.brand}] {p.product_name} | Price: {p.price_range} | Category: {p.category}")
|
||||
|
||||
print("\n=== Q3: Recommend Paneer under ₹150 ===")
|
||||
print(" Is Count:", is_count_query("Recommend Paneer under ₹150"))
|
||||
print(" Brand Mention:", extract_brand_mention("Recommend Paneer under ₹150"))
|
||||
print(" Category:", detected_category("Recommend Paneer under ₹150"))
|
||||
print(" Max Price:", extract_max_price("Recommend Paneer under ₹150"))
|
||||
q3_prods = retrieve("Recommend Paneer under ₹150")
|
||||
for p in q3_prods:
|
||||
print(f" - [{p.brand}] {p.product_name} | Price: {p.price_range} | Category: {p.category}")
|
||||
|
||||
print("\n=== Q4: Suggest low sugar biscuits ===")
|
||||
print(" Category:", detected_category("Suggest low sugar biscuits"))
|
||||
q4_prods = retrieve("Suggest low sugar biscuits")
|
||||
for p in q4_prods:
|
||||
print(f" - [{p.brand}] {p.product_name} | Price: {p.price_range} | Category: {p.category}")
|
||||
@@ -43,8 +43,22 @@ def main() -> None:
|
||||
"--only", nargs="*", default=None,
|
||||
help="Optional list of brand names (case-insensitive substring match on filename) to limit seeding to",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-if-seeded", action="store_true",
|
||||
help="Skip seeding if database already contains products",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.skip_if_seeded:
|
||||
from app.services.vector_store import count_products_all_brands
|
||||
try:
|
||||
cnt = count_products_all_brands()
|
||||
if cnt > 0:
|
||||
logger.info("⚡ Database already contains %d products. Skipping sample data seed.", cnt)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not SEED_DIR.exists():
|
||||
logger.error("Seed directory not found: %s", SEED_DIR)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -34,8 +34,19 @@ def main() -> None:
|
||||
parser.add_argument("--days", type=int, default=90, help="Days of order history to simulate (default: 90)")
|
||||
parser.add_argument("--seed", type=int, default=42, help="Random seed for reproducible store/order generation")
|
||||
parser.add_argument("--no-reset-orders", action="store_true", help="Append to existing order history instead of clearing it first")
|
||||
parser.add_argument("--skip-if-seeded", action="store_true", help="Skip if stores are already provisioned")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.skip_if_seeded:
|
||||
from app.services.store_db import list_stores
|
||||
try:
|
||||
stores = list_stores()
|
||||
if stores and len(stores) >= 5:
|
||||
logger.info("⚡ Stores already provisioned (%d stores). Skipping store intelligence seed.", len(stores))
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from app.services.store_seed_service import run_seed
|
||||
|
||||
logger.info("Seeding store intelligence (days=%d, seed=%d, reset_orders=%s)...", args.days, args.seed, not args.no_reset_orders)
|
||||
|
||||
30
backend/scripts/test_rag.py
Normal file
30
backend/scripts/test_rag.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import sys
|
||||
from app.services.rag_service import retrieve, answer_query
|
||||
from app.services.query_intent import extract_max_price, extract_brand_mention, is_count_query
|
||||
|
||||
def run_test():
|
||||
queries = [
|
||||
"How many products are there in Cadbury?",
|
||||
"Suggest low sugar biscuits",
|
||||
"Recommend Paneer under ₹150",
|
||||
"Suggest panner less than ₹100",
|
||||
]
|
||||
|
||||
for q in queries:
|
||||
print("="*60)
|
||||
print(f"QUERY: {q}")
|
||||
print(f" Is Count: {is_count_query(q)}")
|
||||
print(f" Brand Mention: {extract_brand_mention(q)}")
|
||||
print(f" Max Price: {extract_max_price(q)}")
|
||||
|
||||
prods = retrieve(q)
|
||||
print(" Retrieved Products:")
|
||||
for p in prods[:5]:
|
||||
print(f" - {p.brand} | {p.product_name} | Price: {p.price_range} | Cat: {p.category}")
|
||||
|
||||
ans = answer_query(q)
|
||||
print(f" ANSWER:\n{ans.answer}")
|
||||
print("="*60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_test()
|
||||
12
backend/scripts/test_rag_response.py
Normal file
12
backend/scripts/test_rag_response.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import sys
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
sys.path.insert(0, ".")
|
||||
from app.services.rag_service import answer_query
|
||||
|
||||
res = answer_query("Recommend a low sugar biscuit")
|
||||
print("=== RAG ANSWER ===")
|
||||
print(res.answer)
|
||||
print("=== SOURCES FOUND ===")
|
||||
print(len(res.sources))
|
||||
for p in res.sources[:3]:
|
||||
print(f" - {p.product_name} ({p.brand}) - {p.price_range}")
|
||||
@@ -142,3 +142,37 @@ def test_extracts_spicy() -> None:
|
||||
|
||||
def test_no_attributes_returns_empty_dict() -> None:
|
||||
assert extract_attributes("show me Parle biscuits") == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New Intent Extractor & Typo Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.services.query_intent import is_count_query, extract_max_price, extract_brand_mention
|
||||
|
||||
|
||||
def test_detects_typos_in_category() -> None:
|
||||
assert detect_category_from_text("Suggest panner less than ₹100") == "Dairy"
|
||||
assert detect_category_from_text("Show me biscit options") == "Biscuits & Cookies"
|
||||
assert detect_category_from_text("chocate dark") == "Chocolates"
|
||||
|
||||
|
||||
def test_is_count_query() -> None:
|
||||
assert is_count_query("How many products are there in Cadbury?") is True
|
||||
assert is_count_query("What is the total number of items in Amul?") is True
|
||||
assert is_count_query("how many items under snacks?") is True
|
||||
assert is_count_query("Recommend Paneer under ₹150") is False
|
||||
|
||||
|
||||
def test_extract_max_price() -> None:
|
||||
assert extract_max_price("Recommend Paneer under ₹150") == 150.0
|
||||
assert extract_max_price("Suggest panner less than ₹100") == 100.0
|
||||
assert extract_max_price("biscuits below 50 rupees") == 50.0
|
||||
assert extract_max_price("what paneer do you have?") is None
|
||||
|
||||
|
||||
def test_extract_brand_mention() -> None:
|
||||
assert extract_brand_mention("How many products are there in Cadbury?") == "Cadbury"
|
||||
assert extract_brand_mention("show me Amul butter") == "Amul"
|
||||
assert extract_brand_mention("any Nestle chocolates?") == "Nestle"
|
||||
assert extract_brand_mention("suggest low sugar biscuits") is None
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function App() {
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/nutrition-analytics" element={<NutritionAnalyticsPage />} />
|
||||
<Route path="/nutrition" element={<NutritionAnalyticsPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@@ -157,6 +157,27 @@ export const api = {
|
||||
trainNutritionModels: () => request('/api/admin/nutrition-intelligence/train', { method: 'POST', body: JSON.stringify({}) }),
|
||||
getNutritionIntelligenceJob: (jobId) => request(`/api/admin/nutrition-intelligence/jobs/${jobId}`),
|
||||
getNutritionEnrichmentStatus: () => request('/api/admin/nutrition-intelligence/status'),
|
||||
|
||||
// --- Excel / CSV Upload API ---
|
||||
uploadFile: async (tabType, file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch(`${BASE}/api/upload/${encodeURIComponent(tabType)}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = `Upload failed (${res.status})`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = body.detail || JSON.stringify(body);
|
||||
} catch {}
|
||||
throw new ApiError(detail, res.status);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getTemplateUrl: (tabType) => `${BASE}/api/upload/template/${encodeURIComponent(tabType)}`,
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
117
frontend/src/components/NavigationHeader.jsx
Normal file
117
frontend/src/components/NavigationHeader.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Home, Store, BarChart3, HeartPulse, Wrench, ShoppingBag, AlertTriangle, Sparkles } from 'lucide-react';
|
||||
|
||||
export function NavigationHeader({
|
||||
title,
|
||||
subtitle,
|
||||
icon: TitleIcon,
|
||||
health,
|
||||
onResetHome,
|
||||
children,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const path = location.pathname;
|
||||
|
||||
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">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
{/* Left Section: Home/Back Button + Page Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => {
|
||||
if (onResetHome) onResetHome();
|
||||
}}
|
||||
title="Navigate to Home Page (All Brands Catalog)"
|
||||
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>
|
||||
</Link>
|
||||
|
||||
{title && (
|
||||
<div className="flex items-center gap-2.5 border-l border-ink-900/15 pl-4 py-0.5">
|
||||
{TitleIcon && (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-ink-900/5 text-ink-900">
|
||||
<TitleIcon className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="font-display text-sm font-bold text-ink-950 flex items-center gap-1.5">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="text-[11px] text-slate-400 font-normal leading-tight">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Section: Health Alert + Navigation Tabs */}
|
||||
<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">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{!health.database && !health.ollama
|
||||
? 'DB & Ollama offline'
|
||||
: !health.database
|
||||
? 'Database offline'
|
||||
: 'Ollama offline'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<nav className="flex items-center gap-1 rounded-full bg-ink-100/90 p-1 text-xs font-medium backdrop-blur">
|
||||
<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>
|
||||
|
||||
<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
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/analytics"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/analytics' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="h-3.5 w-3.5" /> Analytics
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/nutrition-analytics"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/nutrition-analytics' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Store, Sparkles, ImageOff, Leaf, ShieldCheck, Barcode } from 'lucide-react';
|
||||
import { Store, Sparkles, ImageOff, Leaf, ShieldCheck, Barcode, Images } from 'lucide-react';
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
const FOOD_CATEGORIES = [
|
||||
@@ -27,29 +27,60 @@ function isFoodCategory(category) {
|
||||
}
|
||||
|
||||
export function ProductCard({ product, onClick, similarity }) {
|
||||
const { product_name, brand, category, price_range, highlights = [], providers = [], image_url, nutrients = [], fssai_license, product_sku, sku_source } = product;
|
||||
const { product_name, brand, category, price_range, highlights = [], providers = [], image_url, image_urls = [], nutrients = [], fssai_license, product_sku, sku_source } = product;
|
||||
|
||||
const allImages = useMemo(() => {
|
||||
const list = [];
|
||||
if (image_url) list.push(image_url);
|
||||
if (Array.isArray(image_urls)) {
|
||||
for (const u of image_urls) {
|
||||
if (u && !list.includes(u)) list.push(u);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [image_url, image_urls]);
|
||||
|
||||
const [imgIndex, setImgIndex] = useState(0);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const showNutrients = useMemo(() => isFoodCategory(category) && nutrients.length > 0, [category, nutrients]);
|
||||
const imageCount = allImages.length;
|
||||
const currentDisplayUrl = allImages[imgIndex];
|
||||
|
||||
const handleImgError = () => {
|
||||
if (imgIndex + 1 < allImages.length) {
|
||||
setImgIndex((prev) => prev + 1);
|
||||
} else {
|
||||
setImgError(true);
|
||||
}
|
||||
};
|
||||
|
||||
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"
|
||||
>
|
||||
{image_url && !imgError ? (
|
||||
<div className="-mx-4 -mt-4 -ml-6 mb-1 overflow-hidden rounded-t-xl">
|
||||
{currentDisplayUrl && !imgError ? (
|
||||
<div className="-mx-4 -mt-4 -ml-6 mb-1 relative overflow-hidden rounded-t-xl">
|
||||
<img
|
||||
src={image_url}
|
||||
key={currentDisplayUrl}
|
||||
src={currentDisplayUrl}
|
||||
alt={product_name}
|
||||
className="h-40 w-full object-contain bg-ink-50"
|
||||
onError={() => setImgError(true)}
|
||||
className="h-40 w-full object-contain bg-ink-50 transition duration-300 group-hover:scale-105"
|
||||
onError={handleImgError}
|
||||
/>
|
||||
{imageCount > 1 && (
|
||||
<span className="absolute right-2 top-2 flex items-center gap-1 rounded-full bg-ink-950/70 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm shadow">
|
||||
<Images className="h-3 w-3" />
|
||||
{imageCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : image_url && imgError ? (
|
||||
) : (
|
||||
<div className="-mx-4 -mt-4 -ml-6 mb-1 flex h-40 items-center justify-center rounded-t-xl bg-ink-50">
|
||||
<ImageOff className="h-8 w-8 text-slate-300" />
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { X, Store, Tag, Leaf, ImageOff, Barcode, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X, Store, Tag, Leaf, ImageOff, Barcode, Sparkles, ChevronLeft, ChevronRight, Images } from 'lucide-react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { NutritionPanel } from './nutrition/NutritionPanel';
|
||||
|
||||
export function ProductModal({ product, onClose, onSelectRecommendation }) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [currentImgIndex, setCurrentImgIndex] = useState(0);
|
||||
const [failedIndices, setFailedIndices] = useState(new Set());
|
||||
const [recommendations, setRecommendations] = useState(null);
|
||||
const [recsLoading, setRecsLoading] = useState(false);
|
||||
|
||||
const images = useMemo(() => {
|
||||
let list = [];
|
||||
if (Array.isArray(product?.image_urls) && product.image_urls.length > 0) {
|
||||
list = product.image_urls.filter(Boolean);
|
||||
} else if (product?.image_url) {
|
||||
list = [product.image_url];
|
||||
}
|
||||
return list;
|
||||
}, [product]);
|
||||
|
||||
useEffect(() => {
|
||||
setImgError(false);
|
||||
setCurrentImgIndex(0);
|
||||
setFailedIndices(new Set());
|
||||
setRecommendations(null);
|
||||
if (!product?.brand || !product?.image_id) return;
|
||||
setRecsLoading(true);
|
||||
@@ -19,32 +31,113 @@ export function ProductModal({ product, onClose, onSelectRecommendation }) {
|
||||
.finally(() => setRecsLoading(false));
|
||||
}, [product?.brand, product?.image_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!product || images.length <= 1) return;
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
setCurrentImgIndex((prev) => (prev - 1 + images.length) % images.length);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
setCurrentImgIndex((prev) => (prev + 1) % images.length);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [product, images]);
|
||||
|
||||
if (!product) return null;
|
||||
const {
|
||||
product_name, brand, category, description, price_range,
|
||||
size_variants = [], providers = [], highlights = [], nutrients = [],
|
||||
image_url, product_sku, sku_source,
|
||||
product_sku, sku_source,
|
||||
} = product;
|
||||
|
||||
const currentUrl = images[currentImgIndex];
|
||||
const isCurrentFailed = failedIndices.has(currentImgIndex);
|
||||
|
||||
const handlePrev = (e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentImgIndex((prev) => (prev - 1 + images.length) % images.length);
|
||||
};
|
||||
|
||||
const handleNext = (e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentImgIndex((prev) => (prev + 1) % images.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/50 p-4" onClick={onClose}>
|
||||
<div className="relative max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-2xl bg-paper-50 p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onClose} className="absolute right-4 top-4 rounded-full p-1.5 text-slate-500 hover:bg-ink-100 hover:text-ink-900">
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 z-20 rounded-full bg-white/80 p-1.5 text-slate-600 backdrop-blur-md transition hover:bg-white hover:text-ink-900 shadow-sm"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{image_url && !imgError ? (
|
||||
<div className="-mx-6 -mt-6 mb-4 overflow-hidden rounded-t-2xl">
|
||||
<img
|
||||
src={image_url}
|
||||
alt={product_name}
|
||||
className="h-56 w-full object-contain bg-ink-50"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
</div>
|
||||
) : image_url && imgError ? (
|
||||
<div className="-mx-6 -mt-6 mb-4 flex h-56 items-center justify-center rounded-t-2xl bg-ink-50">
|
||||
<ImageOff className="h-10 w-10 text-slate-300" />
|
||||
{/* Product Image Section / Carousel */}
|
||||
{images.length > 0 ? (
|
||||
<div className="-mx-6 -mt-6 mb-4">
|
||||
<div className="relative flex h-64 w-full items-center justify-center overflow-hidden rounded-t-2xl bg-ink-50">
|
||||
{currentUrl && !isCurrentFailed ? (
|
||||
<img
|
||||
key={currentUrl}
|
||||
src={currentUrl}
|
||||
alt={`${product_name} - ${currentImgIndex + 1}`}
|
||||
className="h-full w-full object-contain p-2 transition-all duration-300"
|
||||
onError={() => setFailedIndices((prev) => new Set(prev).add(currentImgIndex))}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-ink-50">
|
||||
<ImageOff className="h-10 w-10 text-slate-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Counter / Info Pill */}
|
||||
<div className="absolute left-4 top-4 z-10 flex items-center gap-1.5 rounded-full bg-ink-950/60 px-3 py-1 text-xs font-medium text-white backdrop-blur-md shadow">
|
||||
<Images className="h-3.5 w-3.5" />
|
||||
<span>{currentImgIndex + 1} / {images.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Carousel Left '<' and Right '>' Navigation Buttons */}
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
title="Previous Image (Left Arrow)"
|
||||
className="absolute left-3 top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-ink-950/60 text-white backdrop-blur-md transition hover:bg-ink-950/90 hover:scale-110 active:scale-95 shadow-md"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
title="Next Image (Right Arrow)"
|
||||
className="absolute right-3 top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-ink-950/60 text-white backdrop-blur-md transition hover:bg-ink-950/90 hover:scale-110 active:scale-95 shadow-md"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Navigation Strip */}
|
||||
{images.length > 1 && (
|
||||
<div className="flex items-center gap-2 overflow-x-auto bg-slate-100/70 p-2.5 px-6 border-b border-slate-200/60 no-scrollbar">
|
||||
{images.map((url, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentImgIndex(idx)}
|
||||
className={`relative flex-shrink-0 h-12 w-12 overflow-hidden rounded-lg border-2 transition ${
|
||||
currentImgIndex === idx
|
||||
? 'border-amber-500 ring-2 ring-amber-400/30 scale-105'
|
||||
: 'border-transparent opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img src={url} alt={`Thumb ${idx + 1}`} className="h-full w-full object-cover bg-white" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,81 +1,34 @@
|
||||
import { Search, MessageCircle, LayoutGrid, AlertTriangle, Wrench, Store, BarChart3, HeartPulse } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, health }) {
|
||||
const location = useLocation();
|
||||
import { Search, MessageCircle, LayoutGrid } from 'lucide-react';
|
||||
import { NavigationHeader } from './NavigationHeader';
|
||||
|
||||
export function TopBar({ mode, onModeChange, query, onQueryChange, onSubmit, health, onResetHome }) {
|
||||
return (
|
||||
<header className="flex flex-col gap-3 border-b border-ink-900/10 bg-paper-50/90 px-6 py-4 backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-1 rounded-full bg-ink-100 p-1">
|
||||
<NavigationHeader health={health} onResetHome={onResetHome}>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3 pt-1">
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-full bg-ink-100/80 p-1 text-xs">
|
||||
<ModeButton icon={LayoutGrid} label="Browse & Search" active={mode === 'search'} onClick={() => onModeChange('search')} />
|
||||
<ModeButton icon={MessageCircle} label="Ask AI" active={mode === 'chat'} onClick={() => onModeChange('chat')} />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{!health.database && !health.ollama
|
||||
? 'DB & Ollama unreachable'
|
||||
: !health.database
|
||||
? 'Database unreachable'
|
||||
: 'Ollama unreachable'}
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to="/stores"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
location.pathname === '/stores' ? 'bg-ink-900 text-paper-100' : 'text-slate-500 hover:bg-ink-100'
|
||||
}`}
|
||||
{mode === 'search' && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
className="relative flex-1 w-full"
|
||||
>
|
||||
<Store className="h-3.5 w-3.5" /> Stores
|
||||
</Link>
|
||||
<Link
|
||||
to="/analytics"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
location.pathname === '/analytics' ? 'bg-ink-900 text-paper-100' : 'text-slate-500 hover:bg-ink-100'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="h-3.5 w-3.5" /> Analytics
|
||||
</Link>
|
||||
<Link
|
||||
to="/nutrition-analytics"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
location.pathname === '/nutrition-analytics' ? 'bg-ink-900 text-paper-100' : 'text-slate-500 hover:bg-ink-100'
|
||||
}`}
|
||||
>
|
||||
<HeartPulse className="h-3.5 w-3.5" /> Nutrition
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
location.pathname === '/admin' ? 'bg-ink-900 text-paper-100' : 'text-slate-500 hover:bg-ink-100'
|
||||
}`}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5" /> Admin
|
||||
</Link>
|
||||
</div>
|
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder="Search catalog products — e.g. 'Recommend Paneer under ₹150', 'How many products in Cadbury?'"
|
||||
className="w-full rounded-full border border-ink-900/10 bg-white py-2 pl-10 pr-4 text-xs text-ink-950 placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 shadow-xs"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === 'search' && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder="Search products by name, ingredient, or vibe — e.g. 'low sugar biscuit under 20 rupees'"
|
||||
className="w-full rounded-full border border-ink-900/10 bg-paper-50 py-2.5 pl-10 pr-4 text-sm text-ink-950 placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</header>
|
||||
</NavigationHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,11 +36,11 @@ function ModeButton({ icon: Icon, label, active, onClick }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-sm font-medium transition ${
|
||||
active ? 'bg-paper-50 text-ink-950 shadow-sm' : 'text-slate-500 hover:text-ink-900'
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition ${
|
||||
active ? 'bg-white text-ink-950 shadow-xs' : 'text-slate-500 hover:text-ink-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" /> {label}
|
||||
<Icon className="h-3.5 w-3.5" /> {label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
276
frontend/src/components/UploadExcelModal.jsx
Normal file
276
frontend/src/components/UploadExcelModal.jsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { X, UploadCloud, Download, FileSpreadsheet, CheckCircle2, AlertCircle, Loader2, Table } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export function UploadExcelModal({ isOpen, onClose, tabType, tabTitle, onSuccess }) {
|
||||
const [file, setFile] = useState(null);
|
||||
const [previewRows, setPreviewRows] = useState(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const parsePreview = (selectedFile) => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setFile(selectedFile);
|
||||
|
||||
// Generate quick client-side text preview for CSV/TSV/Text
|
||||
if (selectedFile.name.endsWith('.csv') || selectedFile.name.endsWith('.tsv') || selectedFile.name.endsWith('.txt')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result || '';
|
||||
const lines = text.split('\n').filter((l) => l.trim().length > 0);
|
||||
if (lines.length > 0) {
|
||||
const headers = lines[0].split(/,|\t/).map((h) => h.trim().replace(/^["']|["']$/g, ''));
|
||||
const rows = lines.slice(1, 6).map((line) =>
|
||||
line.split(/,|\t/).map((c) => c.trim().replace(/^["']|["']$/g, ''))
|
||||
);
|
||||
setPreviewRows({ headers, rows, totalLines: lines.length - 1 });
|
||||
}
|
||||
};
|
||||
reader.readAsText(selectedFile.slice(0, 1024 * 10)); // read first 10KB
|
||||
} else {
|
||||
// Excel binary preview placeholder
|
||||
setPreviewRows({ headers: ['Excel File Data Detected'], rows: [[selectedFile.name, `${(selectedFile.size / 1024).toFixed(1)} KB`]], totalLines: 'Excel' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
const droppedFile = e.dataTransfer.files[0];
|
||||
parsePreview(droppedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
parsePreview(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const res = await api.uploadFile(tabType, file);
|
||||
setResult(res);
|
||||
if (onSuccess) onSuccess(res);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to import file. Please check file format.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFile(null);
|
||||
setPreviewRows(null);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/60 p-4 backdrop-blur-xs" onClick={handleClose}>
|
||||
<div
|
||||
className="relative max-h-[90vh] w-full max-w-xl overflow-y-auto rounded-2xl bg-paper-50 p-6 shadow-2xl transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-ink-900/10 pb-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500/10 text-amber-600">
|
||||
<FileSpreadsheet className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-950">
|
||||
Upload {tabTitle || 'Excel / CSV File'}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Import `.xlsx`, `.xls`, or `.csv` records into the database
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-full p-1.5 text-slate-400 hover:bg-ink-100 hover:text-ink-900 transition"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Template Download Option */}
|
||||
<div className="mt-4 flex items-center justify-between rounded-xl bg-amber-500/5 p-3.5 border border-amber-500/20">
|
||||
<div className="text-xs">
|
||||
<p className="font-semibold text-amber-900">Need a sample format?</p>
|
||||
<p className="text-amber-700/80">Download pre-formatted CSV template with example columns.</p>
|
||||
</div>
|
||||
<a
|
||||
href={api.getTemplateUrl(tabType)}
|
||||
download
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-amber-700 transition"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
<span>Sample CSV</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Drag and Drop Zone */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`mt-4 flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed p-6 text-center transition-all ${
|
||||
isDragging
|
||||
? 'border-amber-500 bg-amber-500/10 scale-[1.01]'
|
||||
: file
|
||||
? 'border-leaf-500/60 bg-leaf-500/5'
|
||||
: 'border-ink-900/15 bg-white hover:border-amber-400 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv, .xlsx, .xls, .tsv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 text-amber-600 mb-2">
|
||||
<UploadCloud className="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
{file ? (
|
||||
<div>
|
||||
<p className="font-semibold text-ink-950 text-sm">{file.name}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5 font-mono">
|
||||
{(file.size / 1024).toFixed(1)} KB · Click or drag another file to change
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="font-semibold text-ink-900 text-sm">
|
||||
Click to browse or drag & drop Excel / CSV file
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Supports `.xlsx`, `.xls`, `.csv`, `.tsv`
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Preview Table */}
|
||||
{previewRows && (
|
||||
<div className="mt-4 rounded-xl border border-ink-900/10 bg-white p-3 shadow-xs">
|
||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-slate-100">
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold text-ink-900">
|
||||
<Table className="h-3.5 w-3.5 text-amber-600" />
|
||||
Data Preview (First 5 Rows)
|
||||
</span>
|
||||
{typeof previewRows.totalLines === 'number' && (
|
||||
<span className="text-[11px] font-mono text-slate-400">
|
||||
{previewRows.totalLines} total data rows detected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto max-h-40 no-scrollbar">
|
||||
<table className="w-full text-left text-[11px]">
|
||||
<thead>
|
||||
<tr className="bg-slate-100/70 text-slate-600 font-semibold">
|
||||
{previewRows.headers.map((h, idx) => (
|
||||
<th key={idx} className="p-1.5 whitespace-nowrap font-mono">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 font-sans text-ink-800">
|
||||
{previewRows.rows.map((row, rIdx) => (
|
||||
<tr key={rIdx} className="hover:bg-slate-50">
|
||||
{row.map((cell, cIdx) => (
|
||||
<td key={cIdx} className="p-1.5 whitespace-nowrap">{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="mt-4 flex items-start gap-2 rounded-xl bg-maroon-50 p-3.5 text-xs text-maroon-700 border border-maroon-200">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-maroon-600 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Alert */}
|
||||
{result && (
|
||||
<div className="mt-4 flex items-start gap-2.5 rounded-xl bg-leaf-50 p-3.5 text-xs text-leaf-700 border border-leaf-200">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-leaf-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold text-leaf-900">{result.message}</p>
|
||||
<p className="text-[11px] text-leaf-700/80 mt-0.5">
|
||||
File `{result.filename}` ({result.rows_imported} rows) imported successfully.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex items-center justify-end gap-3 pt-3 border-t border-ink-900/10">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-xl border border-ink-900/15 bg-white px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-slate-50 transition"
|
||||
>
|
||||
{result ? 'Done' : 'Cancel'}
|
||||
</button>
|
||||
|
||||
{!result && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || loading}
|
||||
className={`flex items-center gap-2 rounded-xl px-5 py-2 text-xs font-semibold text-white shadow-sm transition ${
|
||||
!file || loading
|
||||
? 'bg-slate-300 cursor-not-allowed'
|
||||
: 'bg-amber-600 hover:bg-amber-700 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Importing Data...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="h-4 w-4" />
|
||||
<span>Import & Process File</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, PlayCircle, Loader2, CheckCircle2, XCircle, Clock } from 'lucide-react';
|
||||
import { ArrowLeft, PlayCircle, Loader2, CheckCircle2, XCircle, Clock, Wrench } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
pending: { icon: Clock, className: 'text-slate-500 bg-ink-100' },
|
||||
@@ -53,10 +54,14 @@ export function AdminPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-10">
|
||||
<Link to="/" className="mb-6 flex items-center gap-1.5 text-sm text-slate-500 hover:text-ink-900">
|
||||
<ArrowLeft className="h-4 w-4" /> Back to catalog
|
||||
</Link>
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
|
||||
<NavigationHeader
|
||||
title="Catalog Ingestion"
|
||||
subtitle="Discover and index new FMCG brand catalogs into pgvector"
|
||||
icon={Wrench}
|
||||
/>
|
||||
|
||||
<div className="mx-auto w-full max-w-2xl px-6 py-8">
|
||||
|
||||
<h1 className="font-display text-2xl font-bold text-ink-950">Catalog ingestion</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
@@ -127,6 +132,7 @@ export function AdminPage() {
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis,
|
||||
Tooltip, ResponsiveContainer, CartesianGrid, Legend,
|
||||
} from 'recharts';
|
||||
import { Flame, Trophy, TrendingUp, Award } from 'lucide-react';
|
||||
import { Flame, Trophy, TrendingUp, Award, BarChart3 as BarChartIcon, UploadCloud } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Spinner, EmptyState } from '../components/Atoms';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { UploadExcelModal } from '../components/UploadExcelModal';
|
||||
|
||||
const PALETTE = ['#e2a33d', '#3f8556', '#8b2e3c', '#2c3e63', '#c2872a'];
|
||||
|
||||
@@ -20,6 +22,15 @@ export function AnalyticsPage() {
|
||||
|
||||
const [topProducts, setTopProducts] = useState([]);
|
||||
const [topBy, setTopBy] = useState('revenue');
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
|
||||
const reloadData = useCallback(() => {
|
||||
setComparisonLoading(true);
|
||||
api.getStoreComparison().then(setComparison).catch(() => setComparison(null)).finally(() => setComparisonLoading(false));
|
||||
setTrendingLoading(true);
|
||||
api.getTrending({ window: window_, scope: 'overall', top_k: 10 }).then(setTrending).catch(() => setTrending([])).finally(() => setTrendingLoading(false));
|
||||
api.getTopProducts({ by: topBy, order: 'top', limit: 8 }).then(setTopProducts).catch(() => setTopProducts([]));
|
||||
}, [window_, topBy]);
|
||||
|
||||
useEffect(() => {
|
||||
api.getStoreComparison().then(setComparison).catch(() => setComparison(null)).finally(() => setComparisonLoading(false));
|
||||
@@ -39,7 +50,17 @@ export function AnalyticsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
|
||||
<PageHeader />
|
||||
<NavigationHeader title="Chain Analytics" subtitle="Store comparison, trending products & top sellers" icon={BarChartIcon}>
|
||||
<div className="flex items-center justify-end px-6 pb-1 -mt-2">
|
||||
<button
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-amber-600 px-3.5 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-700 shadow-xs active:scale-95"
|
||||
>
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
<span>Upload Sales Analytics (Excel / CSV)</span>
|
||||
</button>
|
||||
</div>
|
||||
</NavigationHeader>
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl flex-1 px-6 py-6">
|
||||
{comparisonLoading ? (
|
||||
@@ -189,6 +210,14 @@ export function AnalyticsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UploadExcelModal
|
||||
isOpen={isUploadOpen}
|
||||
onClose={() => setIsUploadOpen(false)}
|
||||
tabType="analytics"
|
||||
tabTitle="Sales Analytics Data"
|
||||
onSuccess={() => reloadData()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -218,20 +247,3 @@ function prettify(imageId) {
|
||||
const parts = imageId.split('_').filter((p, i, arr) => !(i > 0 && arr[i - 1] === p));
|
||||
return parts.join(' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function PageHeader() {
|
||||
return (
|
||||
<header className="flex items-center justify-between border-b border-ink-900/10 bg-paper-50/90 px-6 py-4 backdrop-blur">
|
||||
<div>
|
||||
<h1 className="font-display text-lg font-bold text-ink-950">Analytics</h1>
|
||||
<p className="text-xs text-slate-400">Store comparison, trending products & top sellers</p>
|
||||
</div>
|
||||
<nav className="flex items-center gap-1 rounded-full bg-ink-100 p-1 text-sm">
|
||||
<Link to="/" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Catalog</Link>
|
||||
<Link to="/stores" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Stores</Link>
|
||||
<span className="rounded-full bg-paper-50 px-3.5 py-1.5 font-medium text-ink-950 shadow-sm">Analytics</span>
|
||||
<Link to="/nutrition-analytics" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Nutrition</Link>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,6 +142,12 @@ export function HomePage() {
|
||||
onQueryChange={setQuery}
|
||||
onSubmit={runSearch}
|
||||
health={health}
|
||||
onResetHome={() => {
|
||||
setSelectedBrand(null);
|
||||
setSelectedCategory(null);
|
||||
setQuery('');
|
||||
setMode('search');
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
Tooltip, ResponsiveContainer, CartesianGrid,
|
||||
} from 'recharts';
|
||||
import {
|
||||
Apple, Award, Beef, Droplet, Flame, HeartPulse, Play, RefreshCw, Wheat,
|
||||
Apple, Award, Beef, Droplet, Flame, HeartPulse, Play, RefreshCw, Wheat, UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Spinner, EmptyState } from '../components/Atoms';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { UploadExcelModal } from '../components/UploadExcelModal';
|
||||
|
||||
const PALETTE = ['#e2a33d', '#3f8556', '#8b2e3c', '#2c3e63', '#c2872a'];
|
||||
|
||||
@@ -16,6 +18,7 @@ export function NutritionAnalyticsPage() {
|
||||
const [dashboard, setDashboard] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [job, setJob] = useState(null);
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
const pollRef = useRef(null);
|
||||
|
||||
const load = () => {
|
||||
@@ -51,7 +54,11 @@ export function NutritionAnalyticsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
|
||||
<PageHeader />
|
||||
<NavigationHeader
|
||||
title="Nutrition Intelligence"
|
||||
subtitle="Verified nutrition data, health scores & leaderboards across the catalog"
|
||||
icon={HeartPulse}
|
||||
/>
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl flex-1 px-6 py-6">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
@@ -60,7 +67,7 @@ export function NutritionAnalyticsPage() {
|
||||
<p className="text-xs text-slate-400">
|
||||
{hasData
|
||||
? `${enrichmentCounts.verified || 0} verified · ${enrichmentCounts.partial || 0} partial · ${enrichmentCounts.unavailable || 0} unavailable of ${totalTracked} tracked products`
|
||||
: 'No products enriched yet - run enrichment to retrieve verified nutrition data from Open Food Facts.'}
|
||||
: 'No products enriched yet - run enrichment or upload Excel/CSV data.'}
|
||||
</p>
|
||||
{job && (
|
||||
<p className="mt-1 text-xs text-amber-600">
|
||||
@@ -70,20 +77,29 @@ export function NutritionAnalyticsPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-amber-600 px-3.5 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-700 shadow-xs active:scale-95"
|
||||
>
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
<span>Upload Nutrition (Excel / CSV)</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={runEnrich}
|
||||
disabled={job && job.status !== 'done' && job.status !== 'failed'}
|
||||
className="flex items-center gap-1.5 rounded-full bg-ink-900 px-4 py-2 text-xs font-medium text-paper-100 disabled:opacity-40"
|
||||
disabled={job && job.status === 'running'}
|
||||
className="flex items-center gap-1.5 rounded-full bg-ink-900 px-3 py-1.5 text-xs font-semibold text-paper-100 transition hover:bg-ink-800 disabled:opacity-50"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" /> Run enrichment
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${job?.kind === 'enrich' && job.status === 'running' ? 'animate-spin' : ''}`} />
|
||||
Auto Enrich OFF
|
||||
</button>
|
||||
<button
|
||||
onClick={runTrain}
|
||||
disabled={job && job.status !== 'done' && job.status !== 'failed'}
|
||||
className="flex items-center gap-1.5 rounded-full border border-ink-900/15 px-4 py-2 text-xs font-medium text-ink-900 disabled:opacity-40"
|
||||
disabled={job && job.status === 'running'}
|
||||
className="flex items-center gap-1.5 rounded-full bg-leaf-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-leaf-700 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> Train similarity/clustering
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Train Similarity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,6 +164,14 @@ export function NutritionAnalyticsPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UploadExcelModal
|
||||
isOpen={isUploadOpen}
|
||||
onClose={() => setIsUploadOpen(false)}
|
||||
tabType="nutrition"
|
||||
tabTitle="Nutritional Intelligence Facts"
|
||||
onSuccess={() => load()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -190,20 +214,3 @@ function Leaderboard({ title, icon: Icon, items = [], field, unit }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeader() {
|
||||
return (
|
||||
<header className="flex items-center justify-between border-b border-ink-900/10 bg-paper-50/90 px-6 py-4 backdrop-blur">
|
||||
<div>
|
||||
<h1 className="font-display text-lg font-bold text-ink-950">Nutrition Analytics</h1>
|
||||
<p className="text-xs text-slate-400">Verified nutrition data, health scores & leaderboards across the catalog</p>
|
||||
</div>
|
||||
<nav className="flex items-center gap-1 rounded-full bg-ink-100 p-1 text-sm">
|
||||
<Link to="/" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Catalog</Link>
|
||||
<Link to="/stores" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Stores</Link>
|
||||
<Link to="/analytics" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Analytics</Link>
|
||||
<span className="rounded-full bg-paper-50 px-3.5 py-1.5 font-medium text-ink-950 shadow-sm">Nutrition</span>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Store as StoreIcon, TrendingUp, IndianRupee, PackageCheck, PackageX,
|
||||
PackageSearch, BarChart3, Search,
|
||||
PackageSearch, BarChart3, Search, UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Spinner, EmptyState } from '../components/Atoms';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { UploadExcelModal } from '../components/UploadExcelModal';
|
||||
|
||||
const STOCK_STATUS_STYLES = {
|
||||
'In Stock': 'bg-leaf-100 text-leaf-600',
|
||||
@@ -27,6 +29,7 @@ export function StoresPage() {
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [inStockOnly, setInStockOnly] = useState(false);
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getStores()
|
||||
@@ -67,7 +70,7 @@ export function StoresPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col bg-paper-100">
|
||||
<PageHeader />
|
||||
<NavigationHeader title="Store Intelligence" subtitle="Multi-store inventory, pricing & ML-based discounts" icon={StoreIcon} />
|
||||
|
||||
{storesLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center"><Spinner label="Loading stores…" /></div>
|
||||
@@ -104,9 +107,9 @@ export function StoresPage() {
|
||||
<KpiRow dashboard={dashboard} />
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-3">
|
||||
<div className="mt-6 flex items-center justify-between gap-3 flex-wrap">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-950">Products in this store</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
@@ -116,10 +119,18 @@ export function StoresPage() {
|
||||
className="rounded-full border border-ink-900/10 bg-white py-1.5 pl-8 pr-3 text-sm focus:border-amber-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-500 mr-2">
|
||||
<input type="checkbox" checked={inStockOnly} onChange={(e) => setInStockOnly(e.target.checked)} />
|
||||
In stock only
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-amber-600 px-3.5 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-700 shadow-xs active:scale-95"
|
||||
>
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
<span>Upload Excel / CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -180,6 +191,14 @@ export function StoresPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UploadExcelModal
|
||||
isOpen={isUploadOpen}
|
||||
onClose={() => setIsUploadOpen(false)}
|
||||
tabType="stores"
|
||||
tabTitle="Store Inventory & Pricing"
|
||||
onSuccess={() => loadStoreData(selectedStore)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -208,20 +227,3 @@ function KpiRow({ dashboard }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeader() {
|
||||
return (
|
||||
<header className="flex items-center justify-between border-b border-ink-900/10 bg-paper-50/90 px-6 py-4 backdrop-blur">
|
||||
<div>
|
||||
<h1 className="font-display text-lg font-bold text-ink-950">Store Intelligence</h1>
|
||||
<p className="text-xs text-slate-400">Multi-store inventory, pricing & ML-based discounts</p>
|
||||
</div>
|
||||
<nav className="flex items-center gap-1 rounded-full bg-ink-100 p-1 text-sm">
|
||||
<Link to="/" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Catalog</Link>
|
||||
<span className="rounded-full bg-paper-50 px-3.5 py-1.5 font-medium text-ink-950 shadow-sm">Stores</span>
|
||||
<Link to="/analytics" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Analytics</Link>
|
||||
<Link to="/nutrition-analytics" className="rounded-full px-3.5 py-1.5 text-slate-500 hover:text-ink-900">Nutrition</Link>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
155
run_project.py
Normal file
155
run_project.py
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unified Single-Command Project Launcher for Brand Catalog Product LLM & RAG Intelligence Engine.
|
||||
|
||||
Usage:
|
||||
python run_project.py
|
||||
|
||||
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.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent
|
||||
BACKEND_DIR = ROOT_DIR / "backend"
|
||||
FRONTEND_DIR = ROOT_DIR / "frontend"
|
||||
VENV_PYTHON = BACKEND_DIR / "venv" / "Scripts" / "python.exe"
|
||||
|
||||
if not VENV_PYTHON.exists():
|
||||
VENV_PYTHON = Path(sys.executable)
|
||||
|
||||
|
||||
def print_banner():
|
||||
print("\033[96m" + "=" * 70)
|
||||
print(" 🚀 BRAND CATALOG PRODUCT LLM & RAG INTELLIGENCE ENGINE")
|
||||
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 compose_file.exists():
|
||||
try:
|
||||
res = subprocess.run(["docker", "compose", "-f", str(compose_file), "up", "-d"], 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.")
|
||||
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:
|
||||
print("✅ Ollama LLM server is running on http://localhost:11434.")
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not shutil.which("ollama"):
|
||||
print("ℹ️ Ollama CLI not installed/found. Backend will use catalog-grounded fallback responses.")
|
||||
return None
|
||||
|
||||
try:
|
||||
p = subprocess.Popen(["ollama", "serve"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
print("🚀 Automatically launched Ollama LLM server background process.")
|
||||
return p
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ollama auto-launch note: {e} (App will use catalog grounded fallback).")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print_banner()
|
||||
start_docker()
|
||||
p_ollama = start_ollama()
|
||||
|
||||
processes = []
|
||||
if p_ollama:
|
||||
processes.append(p_ollama)
|
||||
|
||||
try:
|
||||
# 1. Start Backend FastAPI Uvicorn
|
||||
print("\n⚡ Starting Backend FastAPI Server (http://localhost:8000)...")
|
||||
backend_cmd = [
|
||||
str(VENV_PYTHON), "-m", "uvicorn", "app.main:app",
|
||||
"--reload", "--host", "0.0.0.0", "--port", "8000"
|
||||
]
|
||||
env_backend = os.environ.copy()
|
||||
env_backend["PYTHONPATH"] = str(BACKEND_DIR)
|
||||
env_backend["PYTHONIOENCODING"] = "utf-8"
|
||||
|
||||
p_backend = subprocess.Popen(
|
||||
backend_cmd, cwd=str(BACKEND_DIR), env=env_backend
|
||||
)
|
||||
processes.append(p_backend)
|
||||
|
||||
# 2. 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(
|
||||
[npm_cmd, "run", "dev"], cwd=str(FRONTEND_DIR), shell=(sys.platform == "win32")
|
||||
)
|
||||
processes.append(p_frontend)
|
||||
|
||||
print("\n\033[92m" + "=" * 70)
|
||||
print(" 🎉 APPLICATION IS LIVE AND READY!")
|
||||
print("=" * 70 + "\033[0m")
|
||||
print(" 👉 Web App UI: \033[94mhttp://localhost:5173\033[0m")
|
||||
print(" 👉 Backend API: \033[94mhttp://localhost:8000\033[0m")
|
||||
print(" 👉 API Docs: \033[94mhttp://localhost:8000/docs\033[0m")
|
||||
print(" 👉 System Status: \033[94mhttp://localhost:8000/api/system/status\033[0m")
|
||||
print("=" * 70)
|
||||
print(" (Press Ctrl+C to stop all servers gracefully)\n")
|
||||
|
||||
# Monitor core servers (backend & frontend)
|
||||
core_processes = [p_backend, p_frontend]
|
||||
while True:
|
||||
time.sleep(1)
|
||||
for p in core_processes:
|
||||
if p.poll() is not None:
|
||||
print(f"⚠️ Core process PID {p.pid} exited with code {p.returncode}")
|
||||
return
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 Stopping all services...")
|
||||
finally:
|
||||
for p in processes:
|
||||
try:
|
||||
p.terminate()
|
||||
p.wait(timeout=3)
|
||||
except Exception:
|
||||
try:
|
||||
p.kill()
|
||||
except Exception:
|
||||
pass
|
||||
print("👋 Goodbye! All processes terminated.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,21 +1,13 @@
|
||||
@echo off
|
||||
echo ======================================
|
||||
echo Kirana AI - Full Application Start
|
||||
echo ======================================
|
||||
title Kirana AI - Full Stack Project Launcher
|
||||
echo ========================================================
|
||||
echo Kirana AI - Brand Catalog Product LLM & RAG System
|
||||
echo ========================================================
|
||||
echo.
|
||||
echo Starting PostgreSQL via Docker...
|
||||
docker compose up -d
|
||||
echo.
|
||||
echo Starting Backend (FastAPI on port 8000)...
|
||||
start "Kirana AI Backend" cmd /k "cd /d %~dp0backend && call venv\Scripts\activate.bat && python scripts\seed_sample_data.py && python -m uvicorn app.main:app --reload --port 8000"
|
||||
timeout /t 5 >nul
|
||||
echo Starting Frontend (Vite on port 5173)...
|
||||
start "Kirana AI Frontend" cmd /k "cd /d %~dp0frontend && npm run dev"
|
||||
echo.
|
||||
echo ======================================
|
||||
echo App is starting up!
|
||||
echo Backend: http://localhost:8000
|
||||
echo Frontend: http://localhost:5173
|
||||
echo API Docs: http://localhost:8000/docs
|
||||
echo ======================================
|
||||
cd /d "%~dp0"
|
||||
if exist "backend\venv\Scripts\python.exe" (
|
||||
backend\venv\Scripts\python.exe run_project.py
|
||||
) else (
|
||||
python run_project.py
|
||||
)
|
||||
pause
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
@echo off
|
||||
echo Starting Kirana AI Backend (FastAPI)...
|
||||
title Kirana AI - Backend FastAPI Server
|
||||
echo Starting Backend API (http://localhost:8000)...
|
||||
cd /d "%~dp0backend"
|
||||
call "venv\Scripts\activate.bat"
|
||||
python scripts\seed_sample_data.py
|
||||
if exist "venv\Scripts\activate.bat" call venv\Scripts\activate.bat
|
||||
python scripts\seed_sample_data.py --skip-if-seeded
|
||||
python -m uvicorn app.main:app --reload --port 8000
|
||||
pause
|
||||
|
||||
Reference in New Issue
Block a user