updates on the backend

This commit is contained in:
sriram
2026-08-11 19:16:01 +05:30
commit c2af4556c6
131 changed files with 546007 additions and 0 deletions

0
app/__init__.py Normal file
View File

0
app/api/__init__.py Normal file
View File

31
app/api/background.py Normal file
View File

@@ -0,0 +1,31 @@
"""
Minimal in-process background job dispatcher for long-running admin jobs
(catalog ingestion, store seeding, ML model training, nutrition
enrichment).
This deliberately does NOT use Starlette's `BackgroundTasks`. BackgroundTasks
run *synchronously after the response is sent*: an async background task is
awaited directly on the server's event loop, and a sync one is awaited in the
request's thread. Either way the request handler does not return until the job
finishes. For jobs that take minutes (LLM calls, web scraping, ML training,
Open Food Facts lookups), that turns a "kick off a job and return 202" endpoint
into a blocking call and, for async tasks, freezes the whole API event loop for
the duration.
A daemon thread returns control to the caller immediately, and the job's
progress stays visible via the job_store polling endpoints the UI already
uses. Daemon threads are a deliberate, documented trade-off (see
`app/api/job_store.py`): state is process-local and not safe across multiple
uvicorn workers - fine for this project's intended single-process, CPU-only
deployment.
"""
from __future__ import annotations
import threading
from typing import Any, Callable
def run_in_background(func: Callable[[], Any], *, name: str) -> None:
"""Start `func` on a new daemon thread and return immediately."""
thread = threading.Thread(target=func, name=name, daemon=True)
thread.start()

59
app/api/job_store.py Normal file
View File

@@ -0,0 +1,59 @@
"""
Tiny in-memory job tracker for background catalog-generation tasks.
Deliberately not a queue/Celery/Redis setup - the original project already
had celery+redis in requirements.txt but nothing wired it up, and adding a
broker is unnecessary operational weight for a single-developer, CPU-only
project. A process-local dict is enough to let the React UI show
"running -> done/failed" status for a brand ingestion job started from the
admin panel.
NOTE: state is lost on server restart, and is per-process (not safe for
multiple uvicorn workers). For this project's intended scale (one backend
process on a personal machine) that's a fine trade-off; see the docs'
"Scaling beyond a single machine" section if this ever needs to change.
"""
from __future__ import annotations
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Dict, Optional
@dataclass
class Job:
job_id: str
brand: str
status: str = "pending" # pending -> running -> done | failed
detail: Optional[str] = None
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
class JobStore:
def __init__(self) -> None:
self._jobs: Dict[str, Job] = {}
self._lock = threading.Lock()
def create(self, brand: str) -> Job:
job = Job(job_id=str(uuid.uuid4()), brand=brand)
with self._lock:
self._jobs[job.job_id] = job
return job
def update(self, job_id: str, status: str, detail: Optional[str] = None) -> None:
with self._lock:
job = self._jobs.get(job_id)
if job:
job.status = status
job.detail = detail
job.updated_at = time.time()
def get(self, job_id: str) -> Optional[Job]:
with self._lock:
return self._jobs.get(job_id)
job_store = JobStore()

View File

@@ -0,0 +1,61 @@
"""Same pattern and trade-offs as `store_job_store.py` (process-local,
in-memory, lost on restart) - kept as its own module since nutrition
enrichment jobs track different progress fields (verified/partial/
unavailable counts) than store seed/train jobs do."""
from __future__ import annotations
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Dict, Optional
@dataclass
class NutritionJob:
job_id: str
kind: str # "enrich" | "train"
status: str = "pending" # pending -> running -> done | failed
detail: Optional[str] = None
result: Optional[dict] = None
processed: int = 0
total: int = 0
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
class NutritionJobStore:
def __init__(self) -> None:
self._jobs: Dict[str, NutritionJob] = {}
self._lock = threading.Lock()
def create(self, kind: str) -> NutritionJob:
job = NutritionJob(job_id=str(uuid.uuid4()), kind=kind)
with self._lock:
self._jobs[job.job_id] = job
return job
def update(self, job_id: str, status: Optional[str] = None, detail: Optional[str] = None,
result: Optional[dict] = None, processed: Optional[int] = None, total: Optional[int] = None) -> None:
with self._lock:
job = self._jobs.get(job_id)
if not job:
return
if status is not None:
job.status = status
if detail is not None:
job.detail = detail
if result is not None:
job.result = result
if processed is not None:
job.processed = processed
if total is not None:
job.total = total
job.updated_at = time.time()
def get(self, job_id: str) -> Optional[NutritionJob]:
with self._lock:
return self._jobs.get(job_id)
nutrition_job_store = NutritionJobStore()

View File

@@ -0,0 +1,130 @@
"""Pydantic response models for the nutrition-intelligence API.
Mirrors the plain-dataclass-of-Optionals style used in `schemas.py` /
`store_schemas.py` - permissive `Optional` fields throughout since a
core promise of this module (Feature 15) is that missing verified data
is represented as `null`, never a fabricated default."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
class NutritionFactsOut(BaseModel):
brand: str
image_id: str
product_name: Optional[str] = None
category: Optional[str] = None
data_status: str # 'verified' | 'partial' | 'unavailable'
data_source: Optional[str] = None
source_url: Optional[str] = None
match_confidence: Optional[float] = None
serving_size_g: Optional[float] = None
serving_size_label: Optional[str] = None
calories_kcal: Optional[float] = None
protein_g: Optional[float] = None
carbohydrates_g: Optional[float] = None
total_sugar_g: Optional[float] = None
added_sugar_g: Optional[float] = None
dietary_fiber_g: Optional[float] = None
total_fat_g: Optional[float] = None
saturated_fat_g: Optional[float] = None
trans_fat_g: Optional[float] = None
cholesterol_mg: Optional[float] = None
sodium_mg: Optional[float] = None
potassium_mg: Optional[float] = None
calcium_mg: Optional[float] = None
iron_mg: Optional[float] = None
magnesium_mg: Optional[float] = None
zinc_mg: Optional[float] = None
vitamin_a_mcg: Optional[float] = None
vitamin_c_mg: Optional[float] = None
vitamin_d_mcg: Optional[float] = None
vitamin_e_mg: Optional[float] = None
omega_3_g: Optional[float] = None
omega_6_g: Optional[float] = None
extended_nutrients: Optional[Dict[str, Any]] = None
per_serving: Optional[Dict[str, Any]] = None
ingredients_text: Optional[str] = None
off_nutriscore: Optional[str] = None
model_config = {"extra": "ignore"}
class NutritionInsightsOut(BaseModel):
brand: str
image_id: str
nutrition_score: Optional[float] = None
health_score: Optional[float] = None
score_breakdown: Optional[Dict[str, Any]] = None
scoring_version: Optional[str] = None
positive_insights: List[str] = []
nutritional_cautions: List[str] = []
ai_summary: Optional[str] = None
diet_tags: List[str] = []
allergens: List[str] = []
nutrition_cluster_label: Optional[str] = None
data_status: str
model_config = {"extra": "ignore"}
class FullNutritionOut(NutritionFactsOut, NutritionInsightsOut):
"""Merged facts + insights - what `GET /nutrition/{brand}/{image_id}` returns."""
pass
class SimilarProductOut(BaseModel):
brand: str
image_id: str
similarity_score: Optional[float] = None
method: Optional[str] = None
class HealthyAlternativeOut(BaseModel):
brand: str
image_id: str
product_name: Optional[str] = None
health_score_delta: Optional[float] = None
reason: Optional[str] = None
class ProductListItemOut(BaseModel):
brand: str
image_id: str
product_name: Optional[str] = None
category: Optional[str] = None
calories_kcal: Optional[float] = None
protein_g: Optional[float] = None
dietary_fiber_g: Optional[float] = None
total_sugar_g: Optional[float] = None
sodium_mg: Optional[float] = None
nutrition_score: Optional[float] = None
health_score: Optional[float] = None
diet_tags: Optional[List[str]] = None
allergens: Optional[List[str]] = None
model_config = {"extra": "ignore"}
class PersonalizedRecommendationOut(BaseModel):
customer_id: str
purchase_pattern: str
avg_protein_g: Optional[float] = None
avg_sugar_g: Optional[float] = None
avg_fat_g: Optional[float] = None
recommendations: List[Dict[str, Any]] = []
class NutritionEnrichmentJobOut(BaseModel):
job_id: str
status: str # 'pending' | 'running' | 'completed' | 'failed'
total_products: Optional[int] = None
processed: Optional[int] = None
verified: Optional[int] = None
partial: Optional[int] = None
unavailable: Optional[int] = None
duration_seconds: Optional[float] = None
error: Optional[str] = None

View File

View File

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

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
import logging
from typing import Any, Dict, List, Literal
from fastapi import APIRouter, HTTPException, Query
from app.services import analytics_service, store_db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/analytics", tags=["analytics"])
@router.get("/store/{store_id}")
def store_dashboard(store_id: str) -> Dict[str, Any]:
"""Feature 4: Sales, profit, and inventory analytics for one store."""
if not store_db.get_store(store_id):
raise HTTPException(status_code=404, detail="Store not found")
return analytics_service.store_dashboard(store_id)
@router.get("/compare")
def compare_stores() -> Dict[str, Any]:
"""Feature 4: Chain-wide store comparison - best/lowest performing,
highest revenue/profit, average order value, simulated footfall."""
return analytics_service.chain_comparison()
@router.get("/product/{brand}/{image_id}")
def product_analytics(brand: str, image_id: str) -> Dict[str, Any]:
"""Feature 5: Full per-product metric set (sales, revenue, profit,
popularity, growth %, store-wise breakdown)."""
result = analytics_service.product_dashboard(brand, image_id)
if result["sales_count"] == 0 and not result["store_wise_sales"]:
raise HTTPException(status_code=404, detail="No analytics data for this product yet")
return result
@router.get("/top-products")
def top_products(
by: Literal["revenue", "units"] = Query(default="revenue"),
order: Literal["top", "lowest"] = Query(default="top"),
limit: int = Query(default=10, le=50),
) -> List[Dict[str, Any]]:
"""Feature 5: Top/lowest selling and highest-revenue products."""
return analytics_service.top_products(by=by, limit=limit, ascending=(order == "lowest"))

120
app/api/routers/auth.py Normal file
View File

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

152
app/api/routers/brands.py Normal file
View File

@@ -0,0 +1,152 @@
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from app.api.schemas import AllProductsOut, BrandsOut, CategoriesOut, ProductListOut, ProductOut
from app.services.s3_service import s3_service
from app.services.vector_store import (
list_available_brands,
list_categories_for_brand,
get_products_by_brand,
get_products_all_brands,
count_products_all_brands,
count_products_by_brand,
get_product_by_image_id,
)
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)
hsn = row.get("hsn_code") or row.get("HSN_Code") or row.get("hsn") or None
if hsn is not None:
hsn = str(hsn).strip() or None
raw_fsp = row.get("final_selling_price") if "final_selling_price" in row else row.get("Final_Selling_Price")
if raw_fsp is None:
raw_fsp = row.get("final_price")
try:
fsp = float(raw_fsp) if raw_fsp is not None and str(raw_fsp).strip() != "" else None
except (ValueError, TypeError):
fsp = None
raw_sp = row.get("selling_price") if "selling_price" in row else row.get("Selling_Price")
try:
sp = float(raw_sp) if raw_sp is not None and str(raw_sp).strip() != "" else None
except (ValueError, TypeError):
sp = None
bcd = row.get("barcode") or row.get("Barcode") or None
if bcd is not None:
bcd = str(bcd).strip() or None
bcd_type = row.get("barcode_type") or row.get("Barcode_Type") or None
if bcd_type is not None:
bcd_type = str(bcd_type).strip() or None
fssai = row.get("fssai_license") or row.get("fssai") or row.get("fssai_number") or row.get("FSSAI_License") or row.get("fssai_lic_no") or None
if fssai is not None:
fssai = str(fssai).strip() or None
return ProductOut(
image_id=image_id,
image_url=primary_url,
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"),
description=row.get("description"),
price_range=row.get("price_range"),
size_variants=list(row.get("size_variants") or []),
providers=list(row.get("providers") or []),
highlights=list(row.get("highlights") or []),
nutrients=list(row.get("nutrients") or []),
fssai_license=fssai,
product_sku=row.get("product_sku") or None,
sku_source=row.get("sku_source") or None,
hsn_code=hsn,
final_selling_price=fsp,
selling_price=sp,
barcode=bcd,
barcode_type=bcd_type,
)
@router.get("/brands", response_model=BrandsOut)
def get_brands() -> BrandsOut:
"""List every brand that currently has a populated table in pgvector."""
return BrandsOut(brands=list_available_brands())
@router.get("/brands/{brand}/categories", response_model=CategoriesOut)
def get_brand_categories(brand: str) -> CategoriesOut:
return CategoriesOut(brand=brand, categories=list_categories_for_brand(brand))
@router.get("/brands/{brand}/products", response_model=ProductListOut)
def get_brand_products(
brand: str,
category: Optional[str] = Query(None, description="Optional category filter"),
limit: int = Query(10000, ge=1, le=100000),
offset: int = Query(0, ge=0),
) -> ProductListOut:
"""Plain (non-semantic) browse listing for a brand - what the React 'Browse' tab uses."""
rows = get_products_by_brand(brand, limit=limit, offset=offset, category=category)
total = count_products_by_brand(brand, category=category)
return ProductListOut(
brand=brand,
total=total,
limit=limit,
offset=offset,
products=[_row_to_product_out(r, brand) for r in rows],
)
@router.get("/products", response_model=AllProductsOut)
def get_all_products(
category: Optional[str] = Query(None, description="Optional category filter"),
limit: int = Query(10000, ge=1, le=100000),
offset: int = Query(0, ge=0),
) -> AllProductsOut:
"""Browse listing across ALL brands — used by the 'All brands' sidebar option."""
rows = get_products_all_brands(limit=limit, offset=offset, category=category)
total = count_products_all_brands(category=category)
return AllProductsOut(total=total, limit=limit, offset=offset, products=[
_row_to_product_out(r, r.get("brand", "")) for r in rows
])
@router.get("/brands/{brand}/products/{image_id}", response_model=ProductOut)
def get_product_detail(brand: str, image_id: str) -> ProductOut:
row = get_product_by_image_id(brand, image_id)
if not row:
raise HTTPException(status_code=404, detail=f"Product '{image_id}' not found for brand '{brand}'")
return _row_to_product_out(row, brand)

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, HTTPException
from app.api.background import run_in_background
from app.api.job_store import job_store
from app.api.schemas import CatalogGenerateRequest, CatalogJobOut
from app.core.ingestion import ingest_brand
logger = logging.getLogger(__name__)
router = APIRouter(tags=["admin"])
async def _run_job(job_id: str, brand: str, max_products: int) -> None:
job_store.update(job_id, "running")
try:
summary = await ingest_brand(brand, max_products=max_products)
job_store.update(job_id, "done", detail=f"{summary['total_products']} products ingested")
except Exception as e: # noqa: BLE001 - surface any failure to the UI
logger.exception("Catalog ingestion job %s failed", job_id)
job_store.update(job_id, "failed", detail=str(e))
@router.post("/catalog/generate", response_model=CatalogJobOut, status_code=202)
def generate_catalog(payload: CatalogGenerateRequest) -> CatalogJobOut:
"""Kick off brand catalog ingestion (discovery -> images -> embeddings ->
pgvector) as a background daemon thread and return immediately with a job id.
NOTE: on an 8GB RAM / CPU-only machine, running ingestion (which loads
the embeddings model and calls Ollama repeatedly) at the same time as
heavy chat traffic will be slow. This is intended as an occasional
admin/maintenance action, not a high-frequency endpoint - the React
admin panel disables concurrent runs for this reason.
A daemon thread is used (not FastAPI/Starlette BackgroundTasks) so the
response is returned before the job starts; see app/api/background.py.
"""
job = job_store.create(payload.brand)
run_in_background(
lambda: asyncio.run(_run_job(job.job_id, payload.brand, payload.max_products)),
name=f"catalog-ingest-{job.job_id[:8]}",
)
return CatalogJobOut(job_id=job.job_id, brand=payload.brand, status=job.status)
@router.get("/catalog/jobs/{job_id}", response_model=CatalogJobOut)
def get_job_status(job_id: str) -> CatalogJobOut:
job = job_store.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return CatalogJobOut(job_id=job.job_id, brand=job.brand, status=job.status, detail=job.detail)

33
app/api/routers/chat.py Normal file
View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from fastapi import APIRouter
from app.api.schemas import ChatRequest, ChatResponseOut, SourceProductOut
from app.services.rag_service import answer_query
router = APIRouter(tags=["chat"])
@router.post("/chat", response_model=ChatResponseOut)
def chat(payload: ChatRequest) -> ChatResponseOut:
"""Conversational RAG endpoint: retrieves the most relevant products
from pgvector, then asks the local Ollama model to answer the
question grounded in that retrieved context. Returns both the
generated answer and the source products it was given, so the UI can
show "based on these products" citations.
"""
history = [turn.model_dump() for turn in (payload.history or [])]
result = answer_query(
query=payload.query,
brand=payload.brand,
top_k=payload.top_k,
category=payload.category,
history=history,
)
return ChatResponseOut(
answer=result.answer,
query=result.query,
brand=result.brand,
detected_category=result.detected_category,
sources=[SourceProductOut(**s.to_dict()) for s in result.sources],
)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import logging
from typing import List
from fastapi import APIRouter, HTTPException
from app.api.store_schemas import DiscountOut
from app.services import discount_service, store_db
logger = logging.getLogger(__name__)
router = APIRouter(tags=["discounts"])
@router.get("/stores/{store_id}/discounts", response_model=List[DiscountOut])
def get_store_discounts(store_id: str) -> List[DiscountOut]:
"""Latest ML-predicted discount for every product in this store.
Reads from the `discount_history` log (populated by the training/
seed script's batch run); falls back to computing fresh if nothing's
been logged yet for this store."""
if not store_db.get_store(store_id):
raise HTTPException(status_code=404, detail="Store not found")
cached = store_db.get_latest_discounts(store_id)
if cached:
return [DiscountOut(**{k: c[k] for k in ("store_id", "brand", "image_id", "original_price", "discount_pct", "final_price", "savings", "model_version")}) for c in cached]
results = discount_service.predict_discounts_for_store(store_id)
return [DiscountOut(**r) for r in results]
@router.get("/stores/{store_id}/discounts/{brand}/{image_id}", response_model=DiscountOut)
def get_product_discount(store_id: str, brand: str, image_id: str) -> DiscountOut:
result = discount_service.predict_discount_for_product(store_id, brand, image_id)
if not result:
raise HTTPException(status_code=404, detail="Product not found in this store")
return DiscountOut(**result)

47
app/api/routers/health.py Normal file
View File

@@ -0,0 +1,47 @@
from __future__ import annotations
import logging
import requests
from fastapi import APIRouter
from app.api.schemas import HealthOut
from app.infrastructure.settings import OLLAMA_BASE_URL, OLLAMA_MODEL_NAME, EMBEDDINGS_MODEL
from app.services.vector_store import _connect # internal, but handy for a connectivity probe
logger = logging.getLogger(__name__)
router = APIRouter(tags=["health"])
def _check_database() -> bool:
try:
conn = _connect()
if conn is None:
return False
conn.close()
return True
except Exception:
return False
def _check_ollama() -> bool:
try:
resp = requests.get(f"{OLLAMA_BASE_URL}/api/tags", timeout=3)
return resp.status_code == 200
except Exception:
return False
@router.get("/health", response_model=HealthOut)
def health() -> HealthOut:
"""Liveness/readiness probe used by the React app to show a banner when
Postgres or Ollama aren't reachable, instead of failing silently."""
db_ok = _check_database()
ollama_ok = _check_ollama()
return HealthOut(
status="ok" if (db_ok and ollama_ok) else "degraded",
database=db_ok,
ollama=ollama_ok,
ollama_model=OLLAMA_MODEL_NAME,
embeddings_model=EMBEDDINGS_MODEL,
)

View File

@@ -0,0 +1,195 @@
from __future__ import annotations
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from app.api.nutrition_schemas import (
FullNutritionOut, HealthyAlternativeOut, NutritionInsightsOut,
PersonalizedRecommendationOut, ProductListItemOut, SimilarProductOut,
)
from app.intelligence import nutrition_recommendation, nutrition_similarity
from app.services import nutrition_alternatives_service, nutrition_analytics_service, nutrition_db
router = APIRouter(prefix="/nutrition", tags=["nutrition"])
# ---------------------------------------------------------------------------
# NOTE ON ROUTE ORDER: static/specific paths are registered BEFORE the
# dynamic `/{brand}/{image_id}` catch-all below. Starlette matches routes
# in registration order, so any static route defined after
# `/{brand}/{image_id}` would be shadowed by it (e.g. `/analytics/dashboard`
# would resolve as brand="analytics", image_id="dashboard"). Keep all
# specific routes above the product-detail block at the bottom.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# GET Nutrition Comparison
# ---------------------------------------------------------------------------
@router.get("/compare")
def compare_products(products: str = Query(..., description="Comma-separated brand:image_id pairs, e.g. 'lays:abc123,kurkure:def456'")) -> dict:
pairs = []
for token in products.split(","):
token = token.strip()
if ":" not in token:
raise HTTPException(status_code=400, detail=f"Invalid product reference '{token}', expected 'brand:image_id'")
brand, image_id = token.split(":", 1)
pairs.append((brand.strip(), image_id.strip()))
if len(pairs) < 2:
raise HTTPException(status_code=400, detail="Provide at least 2 products to compare")
if len(pairs) > 6:
raise HTTPException(status_code=400, detail="Compare at most 6 products at a time")
return {"products": [nutrition_db.get_full_nutrition(b, i) for b, i in pairs]}
# ---------------------------------------------------------------------------
# GET Diet Compatible Products / High Protein / Low Sugar / High Fiber (Feature 12)
# ---------------------------------------------------------------------------
@router.get("/diet/{tag}", response_model=List[ProductListItemOut])
def diet_compatible_products(
tag: str, category: Optional[str] = None, exclude_allergen: Optional[str] = None,
limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0),
) -> List[ProductListItemOut]:
"""`tag` is any value Feature 5 can produce, e.g. 'Vegan',
'Gluten Free', 'High Protein', 'Keto Friendly'."""
results = nutrition_db.query_products(
sort_by="health_score", order="desc", category=category, diet_tag=tag,
exclude_allergen=exclude_allergen, limit=limit, offset=offset,
)
return [ProductListItemOut(**r) for r in results]
def _filtered_list(sort_by: str, order: str, category: Optional[str], limit: int, offset: int) -> List[ProductListItemOut]:
results = nutrition_db.query_products(sort_by=sort_by, order=order, category=category, limit=limit, offset=offset)
return [ProductListItemOut(**r) for r in results]
@router.get("/high-protein", response_model=List[ProductListItemOut])
def high_protein_products(category: Optional[str] = None, limit: int = Query(20, ge=1, le=100), offset: int = 0) -> List[ProductListItemOut]:
return _filtered_list("protein", "desc", category, limit, offset)
@router.get("/low-sugar", response_model=List[ProductListItemOut])
def low_sugar_products(category: Optional[str] = None, limit: int = Query(20, ge=1, le=100), offset: int = 0) -> List[ProductListItemOut]:
return _filtered_list("sugar", "asc", category, limit, offset)
@router.get("/high-fiber", response_model=List[ProductListItemOut])
def high_fiber_products(category: Optional[str] = None, limit: int = Query(20, ge=1, le=100), offset: int = 0) -> List[ProductListItemOut]:
return _filtered_list("fiber", "desc", category, limit, offset)
@router.get("/products", response_model=List[ProductListItemOut])
def filter_products(
sort_by: str = Query("health_score", description="protein|fiber|sugar|sodium|calcium|iron|vitamin_c|calories|health_score|nutrition_score"),
order: str = Query("desc", pattern="^(asc|desc)$"),
category: Optional[str] = None,
diet_tag: Optional[str] = None,
exclude_allergen: Optional[str] = None,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> List[ProductListItemOut]:
"""General-purpose flexible version of the filter endpoints above."""
results = nutrition_db.query_products(
sort_by=sort_by, order=order, category=category, diet_tag=diet_tag,
exclude_allergen=exclude_allergen, limit=limit, offset=offset,
)
return [ProductListItemOut(**r) for r in results]
# ---------------------------------------------------------------------------
# GET Nutrition Analytics (Feature 9)
# ---------------------------------------------------------------------------
@router.get("/analytics/dashboard")
def analytics_dashboard(limit: int = Query(10, ge=1, le=50)) -> dict:
return nutrition_analytics_service.get_full_dashboard(limit)
@router.get("/analytics/leaderboards")
def analytics_leaderboards(limit: int = Query(10, ge=1, le=50)) -> dict:
return nutrition_analytics_service.get_leaderboards(limit)
@router.get("/analytics/rankings")
def analytics_rankings(limit: int = Query(10, ge=1, le=50)) -> dict:
return nutrition_analytics_service.get_brand_category_rankings(limit)
@router.get("/analytics/distribution")
def analytics_distribution() -> dict:
return nutrition_analytics_service.get_distribution()
# ---------------------------------------------------------------------------
# Feature 10: Personalized Nutrition Recommendations
# ---------------------------------------------------------------------------
@router.get("/recommendations/{customer_id}", response_model=PersonalizedRecommendationOut)
def personalized_recommendations(customer_id: str, top_k: int = Query(8, ge=1, le=30)) -> PersonalizedRecommendationOut:
result = nutrition_recommendation.recommend_for_customer(customer_id, top_k)
return PersonalizedRecommendationOut(**result)
# ---------------------------------------------------------------------------
# GET Nutrition / GET Health Score (Features 1-6, 12) - DYNAMIC CATCH-ALL
# MUST stay below every static route above (see the note at the top of
# this file for why).
# ---------------------------------------------------------------------------
@router.get("/{brand}/{image_id}", response_model=FullNutritionOut)
def get_nutrition(brand: str, image_id: str) -> FullNutritionOut:
"""Full nutritional facts + insights for one product (Features 1-6).
Always returns 200 with `data_status="unavailable"` rather than 404
when a product exists but hasn't been enriched yet or has no
verified match - the frontend renders this as
"Nutrition data unavailable", per Feature 15."""
data = nutrition_db.get_full_nutrition(brand, image_id)
return FullNutritionOut(**data)
@router.get("/{brand}/{image_id}/health-score")
def get_health_score(brand: str, image_id: str) -> dict:
insights = nutrition_db.get_nutrition_insights(brand, image_id)
if not insights or insights.get("health_score") is None:
return {"brand": brand, "image_id": image_id, "data_status": "unavailable",
"nutrition_score": None, "health_score": None, "score_breakdown": None}
return {
"brand": brand, "image_id": image_id, "data_status": insights.get("data_status"),
"nutrition_score": insights.get("nutrition_score"), "health_score": insights.get("health_score"),
"score_breakdown": insights.get("score_breakdown"),
}
@router.get("/{brand}/{image_id}/insights", response_model=NutritionInsightsOut)
def get_insights(brand: str, image_id: str) -> NutritionInsightsOut:
insights = nutrition_db.get_nutrition_insights(brand, image_id) or {
"brand": brand, "image_id": image_id, "data_status": "unavailable",
"positive_insights": [], "nutritional_cautions": [], "diet_tags": [], "allergens": [],
}
return NutritionInsightsOut(**insights)
# ---------------------------------------------------------------------------
# GET Healthy Alternatives / GET Similar Nutritious Products (Features 7, 8)
# ---------------------------------------------------------------------------
@router.get("/{brand}/{image_id}/alternatives", response_model=List[HealthyAlternativeOut])
def get_alternatives(brand: str, image_id: str, top_k: int = Query(5, ge=1, le=20)) -> List[HealthyAlternativeOut]:
cached = nutrition_db.get_healthy_alternatives(brand, image_id, top_k)
if cached:
return [HealthyAlternativeOut(**c) for c in cached]
computed = nutrition_alternatives_service.find_alternatives(brand, image_id, top_k)
return [HealthyAlternativeOut(**c) for c in computed]
@router.get("/{brand}/{image_id}/similar", response_model=List[SimilarProductOut])
def get_similar(brand: str, image_id: str, top_k: int = Query(5, ge=1, le=20)) -> List[SimilarProductOut]:
cached = nutrition_db.get_similar_products(brand, image_id, top_k)
if cached:
return [SimilarProductOut(**c) for c in cached]
computed = nutrition_similarity.find_similar(brand, image_id, top_k)
return [SimilarProductOut(**c) for c in computed]

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.api.background import run_in_background
from app.api.nutrition_job_store import nutrition_job_store
from app.services import nutrition_enrichment_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/nutrition-intelligence", tags=["admin", "nutrition"])
class EnrichRequest(BaseModel):
skip_if_verified: bool = True
generate_narrative: bool = True
max_products: int | None = None
def _run_enrich_job(job_id: str, skip_if_verified: bool, generate_narrative: bool, max_products: int | None) -> None:
nutrition_job_store.update(job_id, status="running")
def progress_cb(done: int, total: int) -> None:
nutrition_job_store.update(job_id, processed=done, total=total)
try:
result = nutrition_enrichment_service.enrich_all_products(
skip_if_verified=skip_if_verified, generate_narrative=generate_narrative,
progress_cb=progress_cb, max_products=max_products,
)
nutrition_job_store.update(
job_id, status="done", detail="Enrichment complete",
result={
"total_products": result.total_products, "verified": result.verified,
"partial": result.partial, "unavailable": result.unavailable,
"duration_seconds": result.duration_seconds, "error_count": len(result.errors),
"errors": result.errors[:20],
},
)
except Exception as e: # noqa: BLE001
logger.exception("Nutrition enrichment job %s failed", job_id)
nutrition_job_store.update(job_id, status="failed", detail=str(e))
def _run_train_job(job_id: str) -> None:
nutrition_job_store.update(job_id, status="running")
try:
result = nutrition_enrichment_service.train_all_models()
nutrition_job_store.update(job_id, status="done", detail="Training complete", result=result)
except Exception as e: # noqa: BLE001
logger.exception("Nutrition training job %s failed", job_id)
nutrition_job_store.update(job_id, status="failed", detail=str(e))
@router.post("/enrich", status_code=202)
def enrich_nutrition(payload: EnrichRequest) -> dict:
"""Retrieves verified nutrition data for every product in the
catalog (Open Food Facts), computes transparent scores/insights, and
persists them. Safe to re-run - `skip_if_verified=true` (default)
only re-fetches products that don't already have verified data.
Equivalent to `python scripts/enrich_nutrition.py`."""
job = nutrition_job_store.create("enrich")
run_in_background(
lambda: _run_enrich_job(job.job_id, payload.skip_if_verified, payload.generate_narrative, payload.max_products),
name=f"nutrition-enrich-{job.job_id[:8]}",
)
return {"job_id": job.job_id, "status": job.status}
@router.post("/train", status_code=202)
def train_nutrition_models() -> dict:
"""Trains the nutrition-similarity (KNN/cosine) and nutrition-based
clustering (KMeans) models over the currently enriched catalog.
Run after `/enrich` completes. Equivalent to
`python scripts/train_nutrition_models.py`."""
job = nutrition_job_store.create("train")
run_in_background(
lambda: _run_train_job(job.job_id),
name=f"nutrition-train-{job.job_id[:8]}",
)
return {"job_id": job.job_id, "status": job.status}
@router.get("/jobs/{job_id}")
def get_job(job_id: str) -> dict:
job = nutrition_job_store.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return {
"job_id": job.job_id, "kind": job.kind, "status": job.status, "detail": job.detail,
"processed": job.processed, "total": job.total, "result": job.result,
}
@router.get("/status")
def enrichment_status() -> dict:
"""Quick counts for the admin UI: how many products have verified /
partial / unavailable nutrition data right now."""
from app.services import nutrition_db
return {"counts": nutrition_db.enrichment_status_counts()}

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
import logging
from typing import List
from fastapi import APIRouter, Query
from app.api.store_schemas import RecommendationOut
from app.services import recommendation_service
logger = logging.getLogger(__name__)
router = APIRouter(tags=["recommendations"])
@router.get("/recommendations/{brand}/{image_id}", response_model=List[RecommendationOut])
def get_recommendations(brand: str, image_id: str, top_k: int = Query(default=5, le=20)) -> List[RecommendationOut]:
"""Feature 7: hybrid (embedding + TF-IDF + collaborative + popularity)
product recommendations, each with its own similarity_score."""
recs = recommendation_service.recommend_for_product(brand, image_id, top_k=top_k)
return [RecommendationOut(**r) for r in recs]

29
app/api/routers/search.py Normal file
View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Query
from app.api.schemas import SearchOut, SourceProductOut
from app.services.rag_service import retrieve
router = APIRouter(tags=["search"])
@router.get("/search", response_model=SearchOut)
def semantic_search(
q: str = Query(..., min_length=1, max_length=500, description="Free-text search query"),
brand: Optional[str] = Query(None, description="Restrict search to a single brand"),
category: Optional[str] = Query(None, description="Restrict search to a category"),
top_k: int = Query(10, ge=1, le=50),
) -> SearchOut:
"""Pure vector similarity search over the catalog - no LLM call, just
pgvector ranking. This is what powers the instant search-as-you-type
grid in the React 'Search' tab. For a conversational, LLM-generated
answer use POST /api/chat instead."""
results = retrieve(q, brand=brand, top_k=top_k, category=category)
return SearchOut(
query=q,
brand=brand,
results=[SourceProductOut(**r.to_dict()) for r in results],
)

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException
from app.api.background import run_in_background
from app.api.store_job_store import store_job_store
from app.api.store_schemas import SeedRequest, TrainRequest
from app.services import ml_training_service, store_seed_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/store-intelligence", tags=["admin"])
def _run_seed_job(job_id: str, reset_orders: bool, days: int, seed: int) -> None:
store_job_store.update(job_id, "running")
try:
result = store_seed_service.run_seed(reset_orders=reset_orders, days=days, seed=seed)
store_job_store.update(job_id, "done", detail="Seed complete", result=result)
except Exception as e: # noqa: BLE001
logger.exception("Store-intelligence seed job %s failed", job_id)
store_job_store.update(job_id, "failed", detail=str(e))
def _run_train_job(job_id: str, models) -> None:
store_job_store.update(job_id, "running")
try:
result = ml_training_service.train_all(models=models)
store_job_store.update(job_id, "done", detail="Training complete", result=result)
except Exception as e: # noqa: BLE001
logger.exception("Store-intelligence train job %s failed", job_id)
store_job_store.update(job_id, "failed", detail=str(e))
@router.post("/seed", status_code=202)
def seed_store_intelligence(payload: SeedRequest) -> dict:
"""Provisions the 5 stores + simulates order history (Features 1, 2, 8).
Requires at least one brand already ingested via the existing
catalog pipeline. Equivalent to running
`python scripts/seed_store_intelligence.py`."""
job = store_job_store.create("seed")
run_in_background(
lambda: _run_seed_job(job.job_id, payload.reset_orders, payload.days, payload.seed),
name=f"store-seed-{job.job_id[:8]}",
)
return {"job_id": job.job_id, "status": job.status}
@router.post("/train", status_code=202)
def train_models(payload: TrainRequest) -> dict:
"""Trains every ML model (or a subset) against the seeded data.
Equivalent to running `python scripts/train_ml_models.py`."""
job = store_job_store.create("train")
run_in_background(
lambda: _run_train_job(job.job_id, payload.models),
name=f"store-train-{job.job_id[:8]}",
)
return {"job_id": job.job_id, "status": job.status}
@router.get("/jobs/{job_id}")
def get_job(job_id: str) -> dict:
job = store_job_store.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return {"job_id": job.job_id, "kind": job.kind, "status": job.status, "detail": job.detail, "result": job.result}

77
app/api/routers/stores.py Normal file
View File

@@ -0,0 +1,77 @@
from __future__ import annotations
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from app.api.store_schemas import ProductStorePriceOut, StoreOut, StoreProductOut
from app.services import store_db
logger = logging.getLogger(__name__)
router = APIRouter(tags=["stores"])
@router.get("/stores", response_model=List[StoreOut])
def list_stores() -> List[StoreOut]:
return [StoreOut(**s) for s in store_db.list_stores()]
@router.get("/stores/{store_id}", response_model=StoreOut)
def get_store(store_id: str) -> StoreOut:
store = store_db.get_store(store_id)
if not store:
raise HTTPException(status_code=404, detail="Store not found")
return StoreOut(**store)
@router.get("/stores/{store_id}/products", response_model=List[StoreProductOut])
def get_store_products(
store_id: str,
category: Optional[str] = Query(default=None),
in_stock_only: bool = Query(default=False),
limit: int = Query(default=50, le=500),
offset: int = Query(default=0, ge=0),
) -> List[StoreProductOut]:
if not store_db.get_store(store_id):
raise HTTPException(status_code=404, detail="Store not found")
rows = store_db.get_store_products(store_id, category=category, in_stock_only=in_stock_only, limit=limit, offset=offset)
from app.services import vector_store
for row in rows:
prod = vector_store.get_product_by_image_id(row["brand"], row["image_id"])
if prod:
row["image_url"] = prod.get("image_url")
row["image_urls"] = prod.get("image_urls")
row["fssai_license"] = prod.get("fssai_license")
return [_to_store_product_out(r) for r in rows]
@router.get("/products/{brand}/{image_id}/stores", response_model=List[ProductStorePriceOut])
def get_product_across_stores(brand: str, image_id: str) -> List[ProductStorePriceOut]:
"""Feature 1: compare one product's price/stock across every store
that carries it - the direct answer to "Tata Tea Gold 100g: Store-A
₹79, Store-B ₹82, ..." from the spec."""
rows = store_db.get_product_across_stores(brand, image_id)
if not rows:
raise HTTPException(status_code=404, detail="Product not found in any store")
return [ProductStorePriceOut(**r) for r in rows]
def _to_store_product_out(row: dict) -> StoreProductOut:
from app.intelligence.analytics import classify_stock_status
stock_status = classify_stock_status(row["available_stock"], row["reorder_level"], row["safety_stock"])
margin = round(row["selling_price"] - row["cost_price"], 2)
gp_pct = round((margin / row["selling_price"]) * 100, 2) if row["selling_price"] else 0.0
markup_pct = round((margin / row["cost_price"]) * 100, 2) if row["cost_price"] else 0.0
return StoreProductOut(
store_id=row["store_id"], brand=row["brand"], image_id=row["image_id"], title=row.get("title"),
category=row.get("category"), available_stock=row["available_stock"], reserved_stock=row["reserved_stock"],
reorder_level=row["reorder_level"], safety_stock=row["safety_stock"], stock_status=stock_status,
mrp=float(row["mrp"]), cost_price=float(row["cost_price"]), selling_price=float(row["selling_price"]),
profit_margin=margin, gross_profit_pct=gp_pct, markup_pct=markup_pct,
image_url=row.get("image_url"), image_urls=row.get("image_urls"),
fssai_license=row.get("fssai_license")
)

90
app/api/routers/system.py Normal file
View 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.",
}

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
import logging
from typing import List, Literal, Optional
from fastapi import APIRouter, HTTPException, Query
from app.api.store_schemas import TrendingItemOut
from app.services import store_db, trending_service
logger = logging.getLogger(__name__)
router = APIRouter(tags=["trending"])
@router.get("/trending", response_model=List[TrendingItemOut])
def get_trending(
window: Literal["today", "weekly", "monthly"] = Query(default="weekly"),
scope: Literal["overall", "category", "store"] = Query(default="overall"),
scope_value: Optional[str] = Query(default=None, description="Category name (scope=category) or store_id (scope=store)"),
top_k: int = Query(default=10, le=50),
) -> List[TrendingItemOut]:
"""Feature 6: Today's / Weekly / Monthly trending, overall,
category-wise, or store-wise. Backed by the trained trending
regressor's predicted scores - never a hardcoded list."""
if scope in ("category", "store") and not scope_value:
raise HTTPException(status_code=422, detail=f"scope_value is required when scope={scope}")
if scope == "store" and not store_db.get_store(scope_value):
raise HTTPException(status_code=404, detail="Store not found")
items = trending_service.get_trending(window, scope, scope_value, top_k)
return [TrendingItemOut(**i) for i in items]

447
app/api/routers/upload.py Normal file
View 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}"}
)

View File

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

152
app/api/schemas.py Normal file
View File

@@ -0,0 +1,152 @@
"""Pydantic request/response models for the FastAPI layer."""
from __future__ import annotations
from typing import List, Optional
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Shared
# ---------------------------------------------------------------------------
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
category: Optional[str] = None
description: Optional[str] = None
price_range: Optional[str] = None
size_variants: List[str] = Field(default_factory=list)
providers: List[str] = Field(default_factory=list)
highlights: List[str] = Field(default_factory=list)
nutrients: List[str] = Field(default_factory=list)
fssai_license: Optional[str] = None
product_sku: Optional[str] = None
sku_source: Optional[str] = None
hsn_code: Optional[str] = None
final_selling_price: Optional[float] = None
selling_price: Optional[float] = None
barcode: Optional[str] = None
barcode_type: Optional[str] = None
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
category: Optional[str] = None
description: Optional[str] = None
price_range: Optional[str] = None
size_variants: List[str] = Field(default_factory=list)
providers: List[str] = Field(default_factory=list)
highlights: List[str] = Field(default_factory=list)
nutrients: List[str] = Field(default_factory=list)
fssai_license: Optional[str] = None
product_sku: Optional[str] = None
sku_source: Optional[str] = None
hsn_code: Optional[str] = None
final_selling_price: Optional[float] = None
selling_price: Optional[float] = None
barcode: Optional[str] = None
barcode_type: Optional[str] = None
similarity: float
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
class HealthOut(BaseModel):
status: str
database: bool
ollama: bool
ollama_model: str
embeddings_model: str
# ---------------------------------------------------------------------------
# Brands / catalog browsing
# ---------------------------------------------------------------------------
class BrandsOut(BaseModel):
brands: List[str]
class CategoriesOut(BaseModel):
brand: str
categories: List[str]
class ProductListOut(BaseModel):
brand: str
total: int
limit: int
offset: int
products: List[ProductOut]
class AllProductsOut(BaseModel):
total: int
limit: int
offset: int
products: List[ProductOut]
# ---------------------------------------------------------------------------
# Semantic search (retrieval only, no LLM generation)
# ---------------------------------------------------------------------------
class SearchOut(BaseModel):
query: str
brand: Optional[str] = None
results: List[SourceProductOut]
# ---------------------------------------------------------------------------
# RAG chat
# ---------------------------------------------------------------------------
class ChatTurn(BaseModel):
role: str = Field(..., description="'user' or 'assistant'")
content: str
class ChatRequest(BaseModel):
query: str = Field(..., min_length=1, max_length=2000)
brand: Optional[str] = Field(None, description="Restrict retrieval to a single brand")
category: Optional[str] = Field(None, description="Restrict retrieval to a category")
top_k: Optional[int] = Field(None, ge=1, le=15)
history: Optional[List[ChatTurn]] = Field(default=None, description="Prior turns for follow-up questions")
class ChatResponseOut(BaseModel):
answer: str
query: str
brand: Optional[str] = None
detected_category: Optional[str] = Field(
None, description="Product category auto-detected from the query and used to scope retrieval, if any"
)
sources: List[SourceProductOut]
# ---------------------------------------------------------------------------
# Catalog generation (admin/ingestion trigger)
# ---------------------------------------------------------------------------
class CatalogGenerateRequest(BaseModel):
brand: str = Field(..., min_length=1, max_length=120)
max_products: int = Field(50, ge=1, le=300)
class CatalogJobOut(BaseModel):
job_id: str
brand: str
status: str
detail: Optional[str] = None

View File

@@ -0,0 +1,56 @@
"""
In-memory job tracker for the store-intelligence seed/train admin
endpoints - same pattern and same trade-offs as `app/api/job_store.py`
(process-local, lost on restart, fine for a single-developer/single-
process deployment), kept as a separate small module rather than
overloading `job_store.Job.brand` for a job type that isn't
brand-specific (seeding stores and training models operate over the
whole catalog, not one brand).
"""
from __future__ import annotations
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Dict, Optional
@dataclass
class StoreJob:
job_id: str
kind: str # "seed" | "train"
status: str = "pending" # pending -> running -> done | failed
detail: Optional[str] = None
result: Optional[dict] = None
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
class StoreJobStore:
def __init__(self) -> None:
self._jobs: Dict[str, StoreJob] = {}
self._lock = threading.Lock()
def create(self, kind: str) -> StoreJob:
job = StoreJob(job_id=str(uuid.uuid4()), kind=kind)
with self._lock:
self._jobs[job.job_id] = job
return job
def update(self, job_id: str, status: str, detail: Optional[str] = None, result: Optional[dict] = None) -> None:
with self._lock:
job = self._jobs.get(job_id)
if job:
job.status = status
job.detail = detail
if result is not None:
job.result = result
job.updated_at = time.time()
def get(self, job_id: str) -> Optional[StoreJob]:
with self._lock:
return self._jobs.get(job_id)
store_job_store = StoreJobStore()

95
app/api/store_schemas.py Normal file
View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class StoreOut(BaseModel):
store_id: str
store_name: str
city: Optional[str] = None
tier: str
footfall_index: float
class StoreProductOut(BaseModel):
store_id: str
brand: str
image_id: str
title: Optional[str] = None
category: Optional[str] = None
available_stock: int
reserved_stock: int
reorder_level: int
safety_stock: int
stock_status: str
mrp: float
cost_price: float
selling_price: float
profit_margin: float
gross_profit_pct: float
markup_pct: float
image_url: Optional[str] = None
image_urls: Optional[List[str]] = None
fssai_license: Optional[str] = None
class ProductStorePriceOut(BaseModel):
store_id: str
store_name: Optional[str] = None
tier: Optional[str] = None
available_stock: int
mrp: float
cost_price: float
selling_price: float
class DiscountOut(BaseModel):
store_id: str
brand: str
image_id: str
original_price: float
discount_pct: float
final_price: float
savings: float
model_version: Optional[str] = None
class TrendingItemOut(BaseModel):
brand: str
image_id: str
trend_score: float
rank: int
class RecommendationOut(BaseModel):
brand: str
image_id: str
similarity_score: float
method: str = "hybrid"
signals: Optional[Dict[str, float]] = None
class SeedRequest(BaseModel):
reset_orders: bool = Field(default=True, description="Clear existing simulated order history before re-simulating")
days: int = Field(default=90, ge=7, le=365)
seed: int = Field(default=42)
class SeedResponse(BaseModel):
stores: int
store_products: Dict[str, int]
orders: int
order_items: int
class TrainRequest(BaseModel):
models: Optional[List[str]] = Field(
default=None,
description="Subset of models to (re)train: discount, trending, popularity, forecast, store_performance, purchase_propensity. Omit to train all.",
)
class TrainResponse(BaseModel):
trained: Dict[str, Dict[str, Any]]

0
app/core/__init__.py Normal file
View File

967
app/core/catalog_engine.py Normal file
View File

@@ -0,0 +1,967 @@
"""
AI Product Catalog Engine
Main orchestrator that combines a local Ollama LLM with an open-source,
pure-Python image discovery pipeline (Open*Facts / Wikimedia Commons /
DuckDuckGo / Google CSE, with a Playwright-rendered Bing search as a last
resort). The Node.js/Crawlee scraping layer that used to live here has
been removed - see app/services/image_search.py and
app/services/playwright_image_fallback.py for details.
"""
import asyncio
import json
import sys
from pathlib import Path
from typing import Dict, List, Optional, Any
import logging
# Add app directory to path
sys.path.append(str(Path(__file__).parent.parent))
from app.services.ollama_service import fetch_brand_catalog_with_gemini, fetch_brand_catalog_exhaustive, fetch_product_details
from app.services.image_search import find_all_image_urls, find_product_quantity_openfacts
from app.infrastructure.settings import USE_OLLAMA
from app.services.embeddings_service import embed_texts
from app.services.vector_store import ensure_brand_schema, upsert_brand_products, get_existing_product_image_id
from app.services.s3_service import s3_service
from app.services.brand_registry import resolve_parent_brand
from app.services import price_estimator
from app.services.category_registry import detect_category_from_text, sanitize_category_language
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def generate_product_highlights(product: Dict[str, Any], brand: str) -> List[str]:
"""
Generate key highlights and selling points for a product
"""
# Ensure we have a dictionary
if not isinstance(product, dict):
product = {}
highlights = []
title = str(product.get('title', '')).strip()
description = str(product.get('description', '')).strip()
category = str(product.get('category', '')).strip()
price_range = str(product.get('price_range', '')).strip()
size_variants = product.get('size_variants', [])
# Ensure size_variants is a list
if not isinstance(size_variants, list):
size_variants = []
# Brand highlight
if brand:
highlights.append(f"{brand} Brand - Trusted Quality")
# Category highlight
if category and category != 'Uncategorized':
highlights.append(f"{category} Category")
# Size variety highlight - show sizes with prices if available
if len(size_variants) > 1:
size_display = []
for v in size_variants:
if isinstance(v, dict):
size = v.get('size', '')
price = v.get('price', '')
if size and price:
size_display.append(f"{size} - {price}")
elif size:
size_display.append(size)
elif isinstance(v, str) and v.strip():
# Check if it already has price format
if ' - ₹' in v:
size_display.append(v)
else:
size_display.append(v)
if size_display:
highlights.append(f"Available in {len(size_display)} sizes: {', '.join(size_display[:3])}")
elif len(size_variants) == 1:
# Single size variant
v = size_variants[0]
if isinstance(v, dict):
size = v.get('size', '')
price = v.get('price', '')
if size and price:
highlights.append(f"Available in {size} - {price}")
elif size:
highlights.append(f"Available in {size}")
elif isinstance(v, str) and v.strip():
highlights.append(f"Available in {v}")
# Quality indicators from description
desc_lower = description.lower() if description else ''
quality_indicators = [
('crispy', 'Crispy Texture'),
('fresh', 'Fresh & Natural'),
('healthy', 'Healthy Choice'),
('premium', 'Premium Quality'),
('organic', 'Organic Ingredients'),
('low fat', 'Low Fat'),
('sugar free', 'Sugar Free'),
('gluten free', 'Gluten Free'),
('whole grain', 'Whole Grain'),
('fortified', 'Fortified with Vitamins'),
('no preservatives', 'No Preservatives'),
('baked', 'Baked, Not Fried'),
('crunchy', 'Crunchy Bite'),
('soft', 'Soft & Tender'),
('flaky', 'Flaky Layers'),
('rich', 'Rich Taste'),
('mild', 'Mild Flavor'),
('spicy', 'Spicy Kick'),
('sweet', 'Sweet Taste'),
('savory', 'Savory Flavor')
]
for indicator, highlight in quality_indicators:
if indicator in desc_lower and highlight not in highlights:
highlights.append(highlight)
break # Only add one quality indicator
# Image count highlight - removed as it's not a customer benefit
# Provider availability highlight
providers = product.get('providers', [])
if len(providers) > 1:
highlights.append(f"Available on {len(providers)} platforms")
# Value proposition based on category
if 'biscuit' in category.lower() or 'cookie' in category.lower():
highlights.append("Perfect with Tea/Coffee")
elif 'snack' in category.lower():
highlights.append("Great for Snacking")
elif 'bread' in category.lower():
highlights.append("Daily Bread Option")
elif 'milk' in category.lower() or 'dairy' in category.lower():
highlights.append("Rich in Calcium")
# Ensure we have at least 3 highlights
if len(highlights) < 3:
highlights.extend([
"Popular Choice",
"Easy to Find",
"Customer Favorite"
])
return highlights[:6] # Return max 6 highlights
def generate_nutrients_info(product: Dict[str, Any], brand: str) -> List[str]:
"""
Generate nutritional information for a product based on category and title
"""
# Ensure we have a dictionary
if not isinstance(product, dict):
product = {}
nutrients = []
title = str(product.get('title', '')).lower()
category = str(product.get('category', '')).lower()
description = str(product.get('description', '')).lower()
# Combine all text for analysis
all_text = f"{title} {category} {description}"
# Vitamin content based on product type
if any(word in all_text for word in ['biscuit', 'cookie', 'cracker', 'bread', 'cereal']):
nutrients.extend([
"Vitamin B1 (Thiamine) - Energy metabolism",
"Vitamin B2 (Riboflavin) - Cell function",
"Vitamin B3 (Niacin) - Nervous system",
"Iron - Blood health",
"Folic Acid - Cell growth"
])
if any(word in all_text for word in ['milk', 'dairy', 'yogurt', 'cheese']):
nutrients.extend([
"Vitamin A - Vision health",
"Vitamin D - Bone strength",
"Calcium - Strong bones",
"Protein - Muscle building",
"Vitamin B12 - Nerve function"
])
if any(word in all_text for word in ['fruit', 'juice', 'jam', 'preserve']):
nutrients.extend([
"Vitamin C - Immune system",
"Vitamin A - Antioxidant",
"Fiber - Digestive health",
"Potassium - Heart health",
"Folate - Cell division"
])
if any(word in all_text for word in ['nut', 'almond', 'cashew', 'walnut']):
nutrients.extend([
"Vitamin E - Antioxidant",
"Magnesium - Muscle function",
"Omega-3 - Heart health",
"Protein - Muscle building",
"Fiber - Digestive health"
])
# General nutrients for any food product
if not nutrients:
nutrients.extend([
"Vitamin B Complex - Energy",
"Iron - Blood health",
"Fiber - Digestive health",
"Protein - Muscle building",
"Calcium - Bone strength"
])
# Add specific nutrients based on keywords in description
if 'fortified' in all_text or 'enriched' in all_text:
nutrients.extend([
"Fortified with Essential Vitamins",
"Enriched with Minerals"
])
if 'whole grain' in all_text or 'wheat' in all_text:
nutrients.extend([
"Whole Grain Fiber",
"Complex Carbohydrates"
])
if 'organic' in all_text:
nutrients.extend([
"Organic Nutrients",
"Natural Vitamins"
])
# Remove duplicates and limit to 8 nutrients
unique_nutrients = []
for nutrient in nutrients:
if nutrient not in unique_nutrients:
unique_nutrients.append(nutrient)
return unique_nutrients[:8]
def generate_detailed_description(product: Dict[str, Any], brand: str) -> str:
"""
Generate a detailed, content-rich description for a product
"""
# Ensure we have a dictionary and extract values safely
if not isinstance(product, dict):
product = {}
title = str(product.get('title', '')).strip()
category = str(product.get('category', '')).strip()
price_range = str(product.get('price_range', '')).strip()
size_variants = product.get('size_variants', [])
# Ensure size_variants is a list
if not isinstance(size_variants, list):
size_variants = []
# Base description components
description_parts = []
# Opening with brand and product
if brand and title:
description_parts.append(f"Introducing {title} from the trusted {brand} brand, a premium quality product that delivers exceptional taste and satisfaction.")
# Category-specific descriptions
title_lower = title.lower() if title else ''
category_lower = category.lower() if category else ''
if any(word in title_lower for word in ['biscuit', 'cookie']):
description_parts.append("These exquisite biscuits are meticulously crafted using premium ingredients and time-honored recipes that have been perfected over generations. Each bite delivers an irresistible combination of rich, buttery flavor and satisfying crunch that melts beautifully in your mouth. Made with the finest flour, real butter, and carefully selected spices, these biscuits offer a luxurious snacking experience that's perfect for morning tea, afternoon breaks, or evening relaxation. The traditional baking process ensures consistent quality and authentic taste that appeals to both young and old. Whether enjoyed alone or paired with your favorite beverage, these biscuits provide a moment of pure indulgence that transforms any ordinary day into something special. The carefully balanced texture and flavor profile make them an ideal choice for gifting, entertaining guests, or simply treating yourself to a premium snack experience.")
elif any(word in title_lower for word in ['bread', 'loaf']):
description_parts.append("This freshly baked bread is crafted using traditional methods and premium ingredients, delivering an authentic taste that brings warmth to every meal. Made with carefully selected flour, natural yeast, and pure water, each loaf features a golden crust and soft, airy interior that's perfect for sandwiches, toast, or simply enjoyed with butter. The slow fermentation process develops complex flavors and ensures optimal texture that stays fresh longer. Whether you're making breakfast toast, lunch sandwiches, or dinner accompaniments, this bread provides the perfect foundation for any culinary creation. The wholesome ingredients and traditional baking techniques make it a healthy choice for the whole family, while the consistent quality and taste make it a reliable staple for your kitchen.")
elif any(word in title_lower for word in ['milk', 'dairy']):
description_parts.append("This premium dairy product is sourced from the finest farms and processed using advanced techniques to preserve its natural goodness and nutritional value. Rich in essential vitamins, minerals, and proteins, it provides a wholesome foundation for your daily nutrition while delivering the pure, authentic taste that only comes from quality ingredients. The creamy texture and fresh flavor make it perfect for drinking directly, adding to tea and coffee, or using in cooking and baking applications. Whether you're preparing breakfast cereals, making smoothies, or creating delicious desserts, this dairy product enhances every recipe with its natural richness and nutritional benefits. The careful processing ensures maximum freshness and extended shelf life, making it a reliable choice for busy households and health-conscious consumers who demand quality and taste in their daily nutrition.")
elif any(word in title_lower for word in ['snack', 'chips', 'namkeen']):
description_parts.append("This irresistible snack combines the perfect crunch with bold, authentic flavors that satisfy your cravings and energize your day. Crafted using traditional recipes and carefully selected ingredients, each piece delivers an explosion of taste that keeps you reaching for more. The expertly balanced seasoning and texture create a snacking experience that's both satisfying and addictive, making it perfect for movie nights, parties, or anytime you need a flavorful pick-me-up. Whether you're enjoying it during work breaks, sharing with friends, or indulging in a solo treat, this snack delivers consistent quality and taste that never disappoints. The convenient packaging and long shelf life make it an ideal choice for busy lifestyles, while the authentic flavors and premium ingredients ensure you're getting the best snacking experience possible.")
elif any(word in title_lower for word in ['cake', 'muffin', 'sweet']):
description_parts.append("This delectable sweet treat is crafted using the finest ingredients and time-tested baking techniques that create an unforgettable taste experience. Each bite offers a perfect harmony of sweetness, texture, and flavor that delights the senses and satisfies your sweet tooth. Made with premium flour, fresh eggs, real butter, and natural flavorings, this confection delivers the authentic taste of homemade goodness with the convenience of store-bought quality. Whether you're celebrating special occasions, treating yourself to a daily indulgence, or sharing with loved ones, this sweet treat brings joy and satisfaction to every moment. The carefully balanced recipe ensures consistent quality and taste, while the rich, moist texture and delightful flavor profile make it a favorite among dessert lovers of all ages.")
else:
description_parts.append("This exceptional product represents the perfect blend of quality craftsmanship and premium ingredients, delivering an experience that exceeds expectations in every way. Meticulously crafted using time-honored techniques and carefully selected materials, it offers superior quality and taste that stands out in today's competitive market. The attention to detail in every aspect of production ensures consistent excellence, while the commitment to using only the finest ingredients guarantees authentic flavor and lasting satisfaction. Whether you're using it for daily needs or special occasions, this product provides reliable performance and exceptional value that makes it a smart choice for discerning consumers. The combination of traditional methods and modern quality control creates a product that not only meets but exceeds the highest standards of excellence.")
# Add size and variety information
if size_variants and len(size_variants) > 1:
sizes = []
for v in size_variants:
if isinstance(v, dict):
s = v.get('size', '')
if s:
sizes.append(s)
elif isinstance(v, str) and v.strip():
sizes.append(v.strip())
if sizes:
description_parts.append(f"Available in multiple convenient sizes including {', '.join(sizes[:3])}{' and more' if len(sizes) > 3 else ''}, making it easy to choose the perfect portion for your needs.")
elif size_variants and len(size_variants) == 1:
v = size_variants[0]
if isinstance(v, dict):
size = v.get('size', '')
elif isinstance(v, str):
size = v.strip()
else:
size = ''
if size:
description_parts.append(f"Conveniently packaged in {size} size, perfect for individual consumption or sharing with family and friends.")
# Add pricing information
if price_range:
description_parts.append(f"Priced affordably at {price_range}, this product offers excellent value for money without compromising on quality.")
# Add quality and health benefits
if any(word in title_lower for word in ['whole', 'grain', 'wheat']):
description_parts.append("Made with whole grains and natural ingredients, this product provides essential nutrients and dietary fiber, making it a healthier choice for conscious consumers.")
elif any(word in title_lower for word in ['low', 'fat', 'sugar']):
description_parts.append("Formulated to be low in fat and sugar while maintaining great taste, this product is perfect for health-conscious individuals who don't want to compromise on flavor.")
elif any(word in title_lower for word in ['organic', 'natural']):
description_parts.append("Made with organic and natural ingredients, this product is free from artificial preservatives and additives, ensuring you get the purest and most natural taste experience.")
# Add usage suggestions
if any(word in title_lower for word in ['biscuit', 'cookie', 'cracker']):
description_parts.append("Perfect for enjoying with tea, coffee, or as a standalone snack. Great for breakfast, evening tea time, or as a quick energy boost during busy days.")
elif any(word in title_lower for word in ['bread', 'loaf']):
description_parts.append("Versatile and convenient, perfect for making sandwiches, toast, or as an accompaniment to meals. Ideal for breakfast, lunch, or dinner preparations.")
elif any(word in title_lower for word in ['milk', 'dairy']):
description_parts.append("Perfect for drinking directly, adding to tea or coffee, or using in cooking and baking. Essential for daily nutrition and culinary needs.")
# Add brand trust and quality assurance
if brand:
description_parts.append(f"Backed by {brand}'s reputation for quality and consistency, this product undergoes rigorous quality checks to ensure it meets the highest standards before reaching your table.")
# Add storage and shelf life information
description_parts.append("Store in a cool, dry place away from direct sunlight. Check the packaging for specific storage instructions and best before date to ensure optimal freshness and taste.")
# Add final value proposition
description_parts.append("Experience the perfect combination of taste, quality, and value that makes this product a favorite among consumers. Whether you're enjoying it alone or sharing with family, it promises to deliver satisfaction with every bite.")
# Join all parts and clean up
full_description = ' '.join(description_parts)
# Ensure minimum length and clean up
if len(full_description) < 200:
full_description += f" This premium {category.lower() if category else 'product'} from {brand} is designed to meet your daily needs with uncompromising quality and taste that you can trust."
return full_description
class ProductCatalogEngine:
"""
Main engine that orchestrates the AI (Ollama) + open-source image
discovery pipeline.
NOTE: this used to be "AI + Crawlee" - the Node.js/Crawlee/Playwright
scraping layer has been removed (see app/services/image_search.py and
app/services/playwright_image_fallback.py for the replacement, pure
Python, pipeline and the reasoning for why). The class/docstring names
are kept close to the original for continuity but no longer depend on
Node.js or npm being installed at all.
"""
def __init__(self):
self.gemini_enabled = USE_OLLAMA
self.max_products = 200
def _generate_mock_price(self, size: str, product_title: str = '', brand: str = '', category: str = '') -> str:
"""Generate a realistic price for a size variant.
This used to be a hand-rolled ₹/gram formula with no grounding in
real retail prices (see price_estimator.py for the full writeup of
why that produced unrealistic numbers). It now delegates to
`price_estimator.estimate_price`, which uses category-specific
price bands calibrated to typical Indian retail MRPs, a non-linear
pack-size curve (small packs cost more per gram, bulk packs cost
less), and a price floor so tiny packs never look absurdly cheap.
"""
price = price_estimator.estimate_price(size, product_title, brand, category)
return f"₹{price}"
def _select_best_images(self, image_urls: List[str], product_title: str, brand: str, max_images: int = 20) -> List[str]:
"""Select the best images from a list of URLs based on quality and relevance"""
if not image_urls:
return []
# Filter and score images
scored_images = []
for url in image_urls:
if not url or not url.startswith('http'):
continue
score = 0
# Domain quality scoring - prioritize reliable sources.
# Includes major Indian e-commerce CDNs, official brand domains
# (Britannia, Cadbury/Mondelez, Parle, Nestle, ITC, etc.), and
# global e-commerce platforms. Add new brands here as needed.
url_lower = url.lower()
if any(domain in url_lower for domain in ['media.britannia.co.in', 'britannia.co.in']):
score += 20 # Britannia official images
elif any(domain in url_lower for domain in ['cadbury', 'mondelezinternational.com', 'mondelez.com', 'cadburygifting.com']):
score += 20 # Cadbury/Mondelez official images
elif any(domain in url_lower for domain in ['nestle', 'nestle.in', 'nestle.com']):
score += 20 # Nestle official images
elif any(domain in url_lower for domain in ['parleproducts.com', 'parle.biz']):
score += 20 # Parle official images
elif any(domain in url_lower for domain in ['itc.in', 'itcportal.com']):
score += 20 # ITC official images
elif any(domain in url_lower for domain in ['bbassets.com', 'bigbasket.com']):
score += 15 # BigBasket - usually reliable
elif any(domain in url_lower for domain in ['flipkart.com']):
score += 12 # Flipkart - usually reliable
elif any(domain in url_lower for domain in ['amazon.in', 'amazon.com']):
score += 10 # Amazon - usually reliable
elif any(domain in url_lower for domain in ['jiomart.com', 'grofers.com']):
score += 5 # JioMart/Grofers - often blocked (lower priority)
else:
score += 8 # Other domains - medium priority
# Image quality indicators
if any(ext in url_lower for ext in ['.jpg', '.jpeg']):
score += 2 # Prefer JPG
elif any(ext in url_lower for ext in ['.png', '.webp']):
score += 1 # Other formats
# Size indicators (larger images are usually better)
if any(size in url_lower for size in ['large', 'high', 'hd', 'original']):
score += 3
elif any(size in url_lower for size in ['medium', 'med']):
score += 2
elif any(size in url_lower for size in ['small', 'thumb']):
score -= 2
# Product relevance
product_words = product_title.lower().split()
for word in product_words:
if word in url_lower:
score += 1
# Brand relevance
if brand.lower() in url_lower:
score += 2
# Product-context positive scoring: prefer URLs that indicate
# a packaged-good photo (bottles, cans, packaging, labels)
# rather than a generic / artistic photo that happens to share
# the product's name.
if any(kw in url_lower for kw in ['packaging', 'product', 'pack', 'bottle', 'can', 'tin', 'jar', 'box', 'container', 'front', 'label']):
score += 3
# Non-product negative scoring: heavily penalize URLs whose
# path contains words that suggest the image is *not* of a
# packaged consumer good (e.g. a car named "Sprite", a
# portrait of a person named "Tiger", etc.).
if any(kw in url_lower for kw in ['car', 'vehicle', 'automotive', 'motorcycle', 'motorsport', 'racing', 'bike']):
score -= 10
if any(kw in url_lower for kw in ['people', 'person', 'portrait', 'celeb', 'actor', 'actress', 'player']):
score -= 10
if any(kw in url_lower for kw in ['animal', 'pet', 'dog', 'cat', 'bird', 'fish', 'nature', 'landscape', 'scenery']):
score -= 8
if any(kw in url_lower for kw in ['tour', 'travel', 'building', 'architecture', 'house', 'garden']):
score -= 6
if any(kw in url_lower for kw in ['flower', 'rose', 'floral', 'petal', 'bouquet', 'botanical', 'plant', 'herb', 'tree', 'nature', 'garden', 'outdoor']):
score -= 8
# Scientific/technical/research content - heavily penalize
# because search engines frequently return cell biology images,
# gene diagrams, amino acid charts, and medical schematics
# when the product name contains common English words like
# "perk", "crunch", "boost", "active", etc.
if any(kw in url_lower for kw in ['cell', 'cells', 'gene', 'genes', 'genome', 'genetic', 'genomics', 'dna', 'rna', 'protein', 'proteins', 'amino', 'enzyme', 'molecular', 'biology', 'biologist', 'laboratory', 'lab', 'microscope', 'microscopy']):
score -= 15
if any(kw in url_lower for kw in ['scientific', 'research', 'journal', 'article', 'figure', 'supplement', 'nutritional', 'ingredient', 'chemical', 'chemistry', 'formula', 'compound']):
score -= 12
if any(kw in url_lower for kw in ['diagram', 'schematic', 'chart', 'graph', 'infographic', 'illustration', 'anatomy', 'cross-section', 'cutaway']):
score -= 10
if any(kw in url_lower for kw in ['clinical', 'medical', 'hospital', 'patient', 'diagnosis', 'treatment', 'therapy', 'surgery', 'pharma', 'drug', 'medicine']):
score -= 10
if any(kw in url_lower for kw in ['steering', 'epas', 'motor', 'engine', 'transmission', 'suspension', 'brake', 'drivetrain', 'automotive-parts']):
score -= 12
# Avoid problematic URLs
if any(bad in url_lower for bad in ['encrypted-tbn', 'googleusercontent', 'data:', 'placeholder']):
score -= 5
scored_images.append((score, url))
# Sort by score (highest first) and take top images
scored_images.sort(key=lambda x: x[0], reverse=True)
best_images = [url for score, url in scored_images[:max_images]]
logger.info(f"📸 Selected {len(best_images)} best images for {product_title}")
return best_images
def search_with_python(self, query: str, brand: str) -> List[str]:
"""Find product images using the pure-Python, open-source image
pipeline (Open*Facts -> Wikimedia Commons -> DuckDuckGo -> Google
CSE -> Playwright/Bing last resort). See image_search.py for the
full source priority order and rationale.
This used to be one of two parallel paths, the other being
`search_with_crawlee()` which shelled out to Node.js/Crawlee
scripts. That path has been removed - on a machine without `npm
install` already run inside cli/ (the state of every fresh
checkout of this project), it always failed after burning up to
180 seconds per product on dead subprocess calls before this
Python path ever got a chance to run, which was the single
biggest cause of "no images get generated" in practice.
"""
try:
return find_all_image_urls(query, brand)
except Exception as e:
logger.error(f"Image search error: {e}")
return []
async def generate_catalog(self, brand: str, search_query: str = None, use_brand_crawl: bool = False, max_products: int = 200) -> Dict[str, Any]:
"""
Main pipeline: Generate comprehensive product catalog.
`use_brand_crawl` is kept for backward API compatibility but is now
a no-op: it used to shell out to a Node.js/Crawlee brand-catalog
scraper (`cli/brand_catalog_scraper.js`), which has been removed
(see image_search.py / playwright_image_fallback.py docstrings for
why the Node/Crawlee layer was replaced project-wide). Product
discovery is handled entirely by the Ollama LLM step below.
"""
logger.info(f"🚀 Starting catalog generation for brand: {brand}")
# Step 1: Discover products via the Ollama LLM.
logger.info("🤖 Using Ollama (exhaustive) for product discovery...")
llm_products = []
if self.gemini_enabled:
llm_result = fetch_brand_catalog_exhaustive(brand, max_products=max_products)
if not llm_result or not llm_result.get('products'):
logger.info("Fallback to basic LLM catalog...")
llm_result = fetch_brand_catalog_with_gemini(brand)
if llm_result and llm_result.get('products'):
llm_products = llm_result['products']
logger.info(f"✅ Ollama found {len(llm_products)} products")
else:
logger.warning("❌ Ollama returned no products")
discovered_products = llm_products
# Build a LLM lookup for enrichment (title -> details) when available
llm_lookup: Dict[str, Dict[str, Any]] = {}
if self.gemini_enabled:
try:
logger.info("🤖 Fetching Ollama catalog for enrichment lookup...")
llm_result = fetch_brand_catalog_exhaustive(brand, max_products=max_products)
if llm_result and llm_result.get('products'):
for p in llm_result['products']:
k = (p.get('title') or '').strip().lower()
if k:
llm_lookup[k] = p
logger.info(f"✅ Ollama lookup loaded: {len(llm_lookup)} titles")
except Exception as e:
logger.warning(f"LLM enrichment lookup failed: {e}")
# Step 2: Enhance each product with comprehensive image search
enhanced_products = []
# Cap products by max_products
discovered_products = discovered_products[:max_products]
total_products_to_process = len(discovered_products)
for i, product in enumerate(discovered_products):
product_title = product.get('title', '')
logger.info(f"🔍 Processing product {i+1}/{total_products_to_process}: {product_title}")
# Strip brand prefix from title if present (avoids redundant
# "Cadbury Perk Cadbury Perk Crunch" style queries that confuse
# image search APIs).
_title_clean = product_title
_brand_lower = brand.lower()
_title_lower = product_title.lower()
if _title_lower.startswith(_brand_lower):
_title_clean = product_title[len(brand):].strip()
if not _title_clean:
_title_clean = product_title
# Build a more specific search query: add product-category
# context words ("chocolate", "wafer", "packaging", etc.) for
# common English-word brand/product names that confuse image
# search (e.g. "Perk", "Crunch", "Boost", "Active").
search_query_full = f"{brand} {_title_clean}"
# Get images from the open-source Python image pipeline
all_images = self.search_with_python(_title_clean, brand)
# Use LLM-derived simple pricing (size_variants, price_ranges, provider_examples)
logger.info(f"💰 Using LLM simple pricing for: {product_title}")
price_analysis = {
'total_products': 0,
'total_variants': 0,
'providers': {},
'variants': {},
'best_deals': [],
}
# Prioritize images by domain and quality
priority_domains = [
'bbassets.com', 'flipkart.com', 'jiomart.com', 'grofers.com',
'media.britannia.co.in', 'britannia.co.in',
'cadbury', 'mondelezinternational.com', 'mondelez.com', 'cadburygifting.com',
'nestle', 'parleproducts.com', 'parle.biz',
'itc.in', 'itcportal.com',
]
prioritized_images = []
other_images = []
for img in all_images:
if any(domain in img.lower() for domain in priority_domains):
prioritized_images.append(img)
else:
other_images.append(img)
# Select exactly 20 best images
all_prioritized = prioritized_images + other_images
final_images = self._select_best_images(all_prioritized, product_title, brand, max_images=20)
# Check if product already exists in DB to avoid duplicates
image_id_val = ""
try:
product_name_val = product.get('product_name') or product_title
existing_id = get_existing_product_image_id(brand, product_name_val)
if existing_id:
image_id_val = existing_id
logger.info(f"Product '{product_name_val}' already exists (image_id={existing_id}), reusing existing data")
except Exception:
pass
s3_uploaded_urls = []
if not image_id_val and final_images:
try:
image_id_val, s3_uploaded_urls = await s3_service.process_product_images(
{'title': product_title, 'product_name': product.get('product_name') or product_title}, final_images, brand, max_images=20
)
except Exception as e:
logger.warning(f"S3 upload failed for {product_title}: {e}")
if not image_id_val:
id_source = product.get('product_name') or product_title or 'unknown_product'
image_id_val = s3_service.generate_image_id(id_source)
# Enrich missing description/pricing via LLM per-product if needed
enriched_desc = product.get('description')
enriched_img = None
size_variants = product.get('size_variants', [])
price_ranges = product.get('price_ranges', {})
price_range_single = product.get('price_range')
provider_examples = product.get('provider_examples', [])
category_value = product.get('category') or 'Uncategorized'
# Try enrichment from LLM lookup using title match first
if (not enriched_desc or not size_variants) and llm_lookup:
key = (product_title or '').strip().lower()
match = llm_lookup.get(key)
if not match:
for t, data in llm_lookup.items():
if key and (key in t or t in key):
match = data
break
if match:
enriched_desc = enriched_desc or match.get('description')
enriched_img = enriched_img or match.get('image_url')
size_variants = size_variants or match.get('size_variants') or []
price_ranges = price_ranges or match.get('price_ranges') or {}
price_range_single = price_range_single or match.get('price_range')
provider_examples = provider_examples or match.get('provider_examples') or []
if category_value == 'Uncategorized':
category_value = match.get('category') or category_value
# Always try per-product enrichment for better data quality
if not enriched_desc or not size_variants or not price_ranges:
logger.info(f"🔍 Enriching {product_title} with per-product LLM call...")
try:
details = fetch_product_details(brand, product_title)
if details:
logger.info(f"✅ LLM enrichment successful for {product_title}")
enriched_desc = enriched_desc or details.get('description')
enriched_img = details.get('image_url')
size_variants = size_variants or details.get('size_variants') or []
price_ranges = price_ranges or details.get('price_ranges') or {}
price_range_single = price_range_single or details.get('price_range')
provider_examples = provider_examples or details.get('provider_examples') or []
logger.info(f"💰 Pricing data: variants={len(size_variants)}, ranges={len(price_ranges)}, providers={len(provider_examples)}")
except Exception as e:
logger.warning(f"Per-product enrichment failed for {product_title}: {e}")
pass
# Enhance description if it's too short or empty
if not enriched_desc or len(enriched_desc.strip()) < 100:
try:
enriched_desc = generate_detailed_description({
'title': product_title or '',
'category': category_value or 'Uncategorized',
'price_range': price_range_single or '',
'size_variants': size_variants or []
}, brand)
except Exception as e:
logger.warning(f"Failed to generate detailed description: {e}")
enriched_desc = f"{product_title} by {brand}. High-quality product suitable for daily use."
elif enriched_desc and len(enriched_desc.strip()) < 200:
# If description exists but is short, enhance it
try:
base_desc = enriched_desc.strip()
enhanced_desc = generate_detailed_description({
'title': product_title or '',
'category': category_value or 'Uncategorized',
'price_range': price_range_single or '',
'size_variants': size_variants or []
}, brand)
# Combine existing description with enhanced details
enriched_desc = f"{base_desc} {enhanced_desc[len(base_desc):] if len(enhanced_desc) > len(base_desc) else enhanced_desc}"
except Exception as e:
logger.warning(f"Failed to enhance existing description: {e}")
# Keep the original description if enhancement fails
# Heuristic categorization if still Uncategorized - uses the same
# keyword registry that the RAG query pipeline uses to detect
# what category a *question* is about, so ingestion and
# retrieval can never classify the same word differently.
if not category_value or category_value == 'Uncategorized':
category_value = detect_category_from_text(product_title or '') or 'General'
# Guard against cross-category noun leakage in the (LLM-generated)
# description - e.g. a Snacks product whose description calls it
# a "biscuit" - since that leakage is what let unrelated products
# surface for category-specific searches (e.g. a biscuit query
# returning a snack). See category_registry.py for details.
if enriched_desc:
enriched_desc = sanitize_category_language(enriched_desc, category_value)
# Sanity-clamp any single price_range string the LLM proposed
# (e.g. "₹5000-8000" for a biscuit packet) against our
# realistic retail anchor before it's used anywhere downstream.
if price_range_single:
llm_avg = price_estimator.parse_price_string(price_range_single)
anchor_lo, anchor_hi = price_estimator.estimate_price_range(
[s.get('size') if isinstance(s, dict) else s for s in (size_variants or [])] or
price_estimator.default_size_variants(category_value, product_title),
product_title, brand, category_value,
)
if llm_avg is None or not (anchor_lo * 0.4 <= llm_avg <= anchor_hi * 2.5):
price_range_single = f"₹{anchor_lo}-{anchor_hi}"
# Process size variants to include prices (like test engine)
processed_size_variants = []
_variant_size_price_pairs = [] # (size_str, price_int) before formatting
if size_variants:
for variant in size_variants:
if isinstance(variant, dict):
size = variant.get('size', '')
raw_price = variant.get('price', '')
if size and raw_price:
# The LLM (a small, locally-run model) frequently
# hallucinates prices that are an order of
# magnitude off. Rather than trusting it blindly,
# clamp it against our realistic retail anchor -
# this is the main fix for "unrealistic prices".
llm_value = price_estimator.parse_price_string(raw_price)
reconciled = price_estimator.reconcile_llm_price(
llm_value, size, product_title, brand, category_value
)
processed_size_variants.append(f"{size} (₹{reconciled})")
_variant_size_price_pairs.append((size, reconciled))
elif size:
reconciled = price_estimator.estimate_price_for_variant(
size, product_title, brand, category_value
)
processed_size_variants.append(f"{size} (₹{reconciled})")
_variant_size_price_pairs.append((size, reconciled))
elif isinstance(variant, str):
reconciled = price_estimator.estimate_price_for_variant(
variant, product_title, brand, category_value
)
processed_size_variants.append(f"{variant} (₹{reconciled})")
_variant_size_price_pairs.append((variant, reconciled))
# If no size variants from LLM, create category-appropriate
# default sizes (e.g. toothpaste gets 40g/80g/150g rather than
# the old one-size-fits-all 100g/200g/500g used for everything).
if not _variant_size_price_pairs:
default_sizes = price_estimator.default_size_variants(category_value, product_title)
# Ground this in a real packaging size where possible:
# Open Food/Beauty/Products Facts reports an actual
# `quantity` field (e.g. "200 g", "1 l") for products it
# has on file, which is far more trustworthy than the
# category preset list (itself just a fallback for when
# *nothing* else is known). If we have one, swap it in for
# the closest preset rather than presenting a size that may
# not actually exist for this exact product.
try:
real_qty = find_product_quantity_openfacts(product_title, brand)
except Exception:
real_qty = None
if real_qty and real_qty not in default_sizes:
default_sizes = [real_qty] + default_sizes[:2]
for size in default_sizes:
mock_price = self._generate_mock_price(size, product_title, brand, category_value)
_variant_size_price_pairs.append((size, int(mock_price.replace('₹', ''))))
# Safety net: a larger pack must never cost less than a smaller
# one of the same product. estimate_price() is already
# internally monotonic (see price_estimator.py), but
# reconcile_llm_price() can still let through individually
# "plausible" LLM numbers per-variant that are inconsistent
# *with each other* (the original "500ml ₹190, 1L ₹25" bug).
# This call sorts by parsed pack size and clamps any decrease.
_variant_size_price_pairs = price_estimator.enforce_monotonic_prices(_variant_size_price_pairs)
for size, price in _variant_size_price_pairs:
processed_size_variants.append(f"{size} - ₹{price}")
# Generate price range if not provided
if not price_range_single and processed_size_variants:
# Extract prices from size variants
prices = []
for variant in processed_size_variants:
if ' - ₹' in variant:
try:
price_str = variant.split(' - ₹')[1]
price_num = float(price_str)
prices.append(price_num)
except:
pass
if prices:
min_price = min(prices)
max_price = max(prices)
price_range_single = f"₹{int(min_price)}-{int(max_price)}"
else:
# Category-aware fallback instead of a flat, arbitrary
# "₹10-100" that made no sense for e.g. personal care.
lo, hi = price_estimator.estimate_price_range(
price_estimator.default_size_variants(category_value, product_title),
product_title, brand, category_value,
)
price_range_single = f"₹{lo}-{hi}"
# Generate product highlights AFTER price calculations
highlights = generate_product_highlights({
'title': product_title,
'description': enriched_desc,
'category': category_value,
'price_range': price_range_single,
'size_variants': processed_size_variants,
'total_images': len(final_images),
'providers': provider_examples
}, brand)
# Generate nutrients information
nutrients = generate_nutrients_info({
'title': product_title,
'description': enriched_desc,
'category': category_value
}, brand)
# Create enhanced product with variant data
# Prefer a real, search-discovered image (already filtered/scored in
# _select_best_images) as the primary image. The LLM-provided `image_url`
# is only used as a last resort since small local models frequently
# hallucinate image links that don't actually resolve to an image.
all_image_urls = s3_uploaded_urls or final_images
primary_image = all_image_urls[0] if all_image_urls else (
enriched_img if enriched_img and str(enriched_img).startswith('http') else None
)
if not primary_image and s3_service.enabled and image_id_val:
primary_image = s3_service.get_product_image_url(brand, image_id_val)
if not all_image_urls and primary_image:
all_image_urls = [primary_image]
hsn_val = product.get('hsn_code') or product.get('HSN_Code') or product.get('hsn')
fsp_val = product.get('final_selling_price') if 'final_selling_price' in product else product.get('Final_Selling_Price')
sp_val = product.get('selling_price') if 'selling_price' in product else product.get('Selling_Price')
bcd_val = product.get('barcode') or product.get('Barcode')
bcd_type_val = product.get('barcode_type') or product.get('Barcode_Type')
enhanced_product = {
**product,
'brand_name': brand,
'image_url': primary_image,
'image_urls': all_image_urls,
'primary_image': primary_image,
'total_images': len(all_image_urls),
'search_query': search_query_full,
'price_analysis': price_analysis,
'description': enriched_desc,
'size_variants': processed_size_variants,
'price_ranges': price_ranges,
'price_range': price_range_single,
'providers': provider_examples,
'best_deals': [],
'total_variants': len(processed_size_variants),
'total_products_found': 0,
'category': category_value,
'image_id': image_id_val,
'highlights': highlights,
'nutrients': nutrients,
'hsn_code': hsn_val,
'final_selling_price': fsp_val,
'selling_price': sp_val,
'barcode': bcd_val,
'barcode_type': bcd_type_val,
}
enhanced_products.append(enhanced_product)
logger.info(f"✅ Enhanced {product_title}: {len(final_images)} images")
# Step 3: Generate final catalog
catalog = {
'brand': brand,
'search_query': search_query,
'generation_timestamp': str(Path().cwd()),
'total_products': len(enhanced_products),
'total_images': sum(p['total_images'] for p in enhanced_products),
'engine_info': {
'gemini_enabled': self.gemini_enabled,
'architecture': 'Ollama LLM + Open-Source Image Pipeline (Open*Facts / Wikimedia / DuckDuckGo / Playwright-last-resort)'
},
'products': enhanced_products
}
logger.info(f"🎉 Catalog generation complete!")
logger.info(f"📊 Products: {catalog['total_products']}")
logger.info(f"🖼️ Total images: {catalog['total_images']}")
# Step 4: Embeddings + Vector DB (optional)
try:
ensure_brand_schema(brand)
# Build texts for embeddings
texts = []
for p in enhanced_products:
title = p.get('title') or ''
desc = p.get('description') or ''
cat = p.get('category') or ''
# Concatenate lightweight context
texts.append(f"{title} [CAT={cat}] :: {desc}")
vectors = embed_texts(texts)
for p, vec in zip(enhanced_products, vectors):
p['embedding'] = vec
# Add image_id if not present
if 'image_id' not in p:
id_source = p.get('product_name') or p.get('title', 'unknown_product')
p['image_id'] = s3_service.generate_image_id(id_source)
upsert_brand_products(brand, enhanced_products, cleanup=True)
logger.info("🧠 Stored embeddings to pgvector")
except Exception as e:
logger.warning(f"Vector storage skipped/failed: {e}")
return catalog
def save_catalog(self, catalog: Dict[str, Any], filename: str = None) -> str:
"""Save catalog to JSON file inside data/ folder"""
if not filename:
brand = catalog.get('brand', 'unknown')
storage_brand = resolve_parent_brand(brand)
safe_brand = storage_brand.replace(' ', '_')
ts = catalog.get('generation_timestamp', 'latest')
filename = f"data/catalog_{safe_brand}_{ts}.json"
Path("data").mkdir(exist_ok=True)
with open(filename, 'w', encoding='utf-8') as f:
json.dump(catalog, f, indent=2, ensure_ascii=False)
logger.info(f"💾 Catalog saved to: {filename}")
return filename
# Global engine instance
catalog_engine = ProductCatalogEngine()

44
app/core/ingestion.py Normal file
View File

@@ -0,0 +1,44 @@
"""
Thin wrapper around `ProductCatalogEngine.generate_catalog`, used by both
the FastAPI admin-trigger endpoint (`POST /api/catalog/generate`) and the
standalone CLI tool (`cli/ingest_brand.py`).
`generate_catalog()` already performs the full pipeline end-to-end:
product discovery (Ollama), per-product image search + S3 upload,
pricing/description enrichment, embedding generation, and the pgvector
upsert. This module exists only to give that pipeline one clear, reusable
entry point and a consistent result shape for callers.
"""
from __future__ import annotations
import logging
import time
from typing import Any, Dict
from app.core.catalog_engine import catalog_engine
logger = logging.getLogger(__name__)
async def ingest_brand(brand: str, max_products: int = 50) -> Dict[str, Any]:
"""Run full discovery -> enrichment -> embedding -> pgvector storage for a brand.
Returns a small summary dict; the full per-product catalog is also
written to pgvector as a side effect (this is where the React app's
data actually comes from on subsequent requests).
"""
start = time.time()
logger.info("Starting ingestion for brand=%s max_products=%s", brand, max_products)
catalog = await catalog_engine.generate_catalog(brand=brand, max_products=max_products)
duration = time.time() - start
summary = {
"brand": brand,
"total_products": catalog.get("total_products", 0),
"total_images": catalog.get("total_images", 0),
"duration_seconds": round(duration, 2),
"engine_info": catalog.get("engine_info", {}),
}
logger.info("Finished ingestion for brand=%s in %.2fs: %s products",
brand, duration, summary["total_products"])
return summary

View File

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

View File

View File

@@ -0,0 +1,149 @@
"""
Centralized configuration for the AI Product Catalog + RAG backend.
SECURITY NOTE
--------------
The previous version of this project had a serious problem: `settings.py`
hard-coded a *live* database host, port and password as Python literal
fallbacks (`os.getenv("DB_HOST", "<real ip>")`, etc.). That means the
real production credentials shipped inside the source code itself - in
every copy, every zip export, and every git commit - regardless of
whether a `.env` file was present.
This rewrite removes every hard-coded secret. Every credential
(DB_PASSWORD, S3 keys, Google API key, ...) is read ONLY from the
environment (via a local `.env` file, loaded through python-dotenv, or
real OS/container environment variables). Non-secret values (ports,
feature flags, model names) keep sane, publicly-safe defaults so the
project still boots out of the box for local development.
If a secret-shaped variable is required for a feature that is enabled
(e.g. `USE_PGVECTOR=true` but no `DB_PASSWORD` set), we raise a clear
`RuntimeError` at settings-load time instead of silently connecting
with an empty/placeholder password. Fail loudly, not insecurely.
"""
from __future__ import annotations
import os
from pathlib import Path
try:
from dotenv import load_dotenv
# backend/.env (one level up from this file: app/infrastructure/settings.py)
_env_path = Path(__file__).resolve().parents[2] / ".env"
load_dotenv(_env_path)
except ImportError:
# python-dotenv not installed - fall back to whatever is already in the
# process environment (e.g. set by the shell, Docker, systemd, CI, etc.)
pass
def _bool(name: str, default: str) -> bool:
return os.getenv(name, default).strip().lower() in {"1", "true", "yes"}
def _require(name: str, *, feature_flag: str) -> str:
"""Read a required secret. Raises if missing and the owning feature is enabled."""
value = os.getenv(name)
if not value:
raise RuntimeError(
f"Missing required environment variable '{name}'. It is required because "
f"'{feature_flag}' is enabled. Set it in backend/.env (copy from "
f".env.example) or disable the feature by setting {feature_flag}=false."
)
return value
# ---------------------------------------------------------------------------
# Ollama (local LLM)
# ---------------------------------------------------------------------------
USE_OLLAMA = _bool("USE_OLLAMA", "true")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL_NAME = os.getenv("OLLAMA_MODEL_NAME", "qwen2.5:1.5b")
# Per-request generation timeout (seconds). Small CPU-only models on modest
# hardware (e.g. 8GB RAM, no GPU) can take a while for longer RAG contexts.
OLLAMA_TIMEOUT_SECONDS = int(os.getenv("OLLAMA_TIMEOUT_SECONDS", "120"))
# ---------------------------------------------------------------------------
# Embeddings (sentence-transformers, CPU-friendly)
# ---------------------------------------------------------------------------
USE_EMBEDDINGS = _bool("USE_EMBEDDINGS", "true")
EMBEDDINGS_MODEL = os.getenv("EMBEDDINGS_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
EMBEDDINGS_DIM = int(os.getenv("EMBEDDINGS_DIM", "384"))
# ---------------------------------------------------------------------------
# Postgres / pgvector
# ---------------------------------------------------------------------------
USE_PGVECTOR = _bool("USE_PGVECTOR", "true")
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "5432")
DB_NAME = os.getenv("DB_NAME", "pgvector")
DB_USER = os.getenv("DB_USER", "postgres")
DB_PASSWORD = _require("DB_PASSWORD", feature_flag="USE_PGVECTOR") if USE_PGVECTOR else os.getenv("DB_PASSWORD", "")
DATABASE_URL = os.getenv(
"DATABASE_URL",
f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}",
)
# ---------------------------------------------------------------------------
# S3 / DigitalOcean Spaces (product image storage) - optional
# ---------------------------------------------------------------------------
USE_S3 = _bool("USE_S3", "false")
S3_ACCESS_KEY = _require("S3_ACCESS_KEY", feature_flag="USE_S3") if USE_S3 else os.getenv("S3_ACCESS_KEY")
S3_SECRET_KEY = _require("S3_SECRET_KEY", feature_flag="USE_S3") if USE_S3 else os.getenv("S3_SECRET_KEY")
S3_ENDPOINT = _require("S3_ENDPOINT", feature_flag="USE_S3") if USE_S3 else os.getenv("S3_ENDPOINT")
S3_BUCKET = _require("S3_BUCKET", feature_flag="USE_S3") if USE_S3 else os.getenv("S3_BUCKET")
S3_REGION = os.getenv("S3_REGION", "sgp1")
# ---------------------------------------------------------------------------
# Google Custom Search (OPTIONAL image source - leave disabled if unset)
# ---------------------------------------------------------------------------
USE_GOOGLE_CSE = _bool("USE_GOOGLE_CSE", "false")
GOOGLE_API_KEY = _require("GOOGLE_API_KEY", feature_flag="USE_GOOGLE_CSE") if USE_GOOGLE_CSE else os.getenv("GOOGLE_API_KEY")
GOOGLE_CSE_ID = _require("GOOGLE_CSE_ID", feature_flag="USE_GOOGLE_CSE") if USE_GOOGLE_CSE else os.getenv("GOOGLE_CSE_ID")
# ---------------------------------------------------------------------------
# Open-source image sources (no API key needed for any of these three)
# ---------------------------------------------------------------------------
USE_DDG_IMAGES = _bool("USE_DDG_IMAGES", "true")
USE_OPEN_FACTS = _bool("USE_OPEN_FACTS", "true")
USE_WIKIMEDIA = _bool("USE_WIKIMEDIA", "true")
# Last-resort headless-browser (Python Playwright) image fallback. Requires
# `pip install playwright && playwright install chromium`; automatically
# skipped (logged once) if that hasn't been done.
USE_PLAYWRIGHT_FALLBACK = _bool("USE_PLAYWRIGHT_FALLBACK", "true")
# Minimum byte size for a downloaded image to be accepted as "real" (filters
# out 1x1 tracking pixels / broken placeholder images)
MIN_IMAGE_BYTES = int(os.getenv("MIN_IMAGE_BYTES", "3000"))
# ---------------------------------------------------------------------------
# HTTP client defaults
# ---------------------------------------------------------------------------
USER_AGENT = os.getenv("USER_AGENT", "CatalogBot/1.0 (+https://example.com)")
REQUEST_TIMEOUT_SECONDS = int(os.getenv("REQUEST_TIMEOUT_SECONDS", "20"))
# ---------------------------------------------------------------------------
# FastAPI / web server
# ---------------------------------------------------------------------------
API_CORS_ORIGINS = [
origin.strip()
for origin in os.getenv("API_CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173").split(",")
if origin.strip()
]
# Default RAG behaviour
RAG_DEFAULT_TOP_K = int(os.getenv("RAG_DEFAULT_TOP_K", "5"))
RAG_MAX_TOP_K = int(os.getenv("RAG_MAX_TOP_K", "15"))
RAG_MAX_CONTEXT_CHARS = int(os.getenv("RAG_MAX_CONTEXT_CHARS", "4000"))
# Optional pgvector cosine-distance ceiling (0 = identical, 2 = opposite)
# used to drop weak semantic matches before they reach the LLM. Left
# unset (None) by default since the primary relevance guardrail is the
# category-aware retrieval in rag_service.py; set RAG_MAX_DISTANCE (e.g.
# "0.9") once you've inspected real distance scores for your embeddings
# if you want an extra cutoff on top of that.
_raw_max_distance = os.getenv("RAG_MAX_DISTANCE", "").strip()
RAG_MAX_DISTANCE = float(_raw_max_distance) if _raw_max_distance else None

View File

@@ -0,0 +1,41 @@
"""
Store Intelligence ML package
==============================
Everything related to the v3.0 "Multi-Store Intelligence" upgrade lives
here: synthetic data generation, feature engineering, and the trained
scikit-learn models for discount prediction, trending detection, demand
forecasting, popularity scoring, store performance, purchase propensity,
and product recommendations.
Design notes (read this before touching the models)
-----------------------------------------------------
1. CPU-only / 8GB RAM target. Every model in this package is a
scikit-learn estimator (RandomForest / GradientBoosting / Logistic
/ Linear regression). We deliberately do NOT use XGBoost, LightGBM,
CatBoost, Prophet, or any deep-learning (LSTM/PyTorch/TensorFlow)
library, even though the feature spec lists them as options - on
this hardware they cost far more RAM/install time than the accuracy
they'd buy at this data scale (a handful of stores, a few thousand
products, tens of thousands of simulated orders). scikit-learn's
GradientBoostingRegressor/RandomForest give comparable accuracy at a
fraction of the footprint and were explicitly listed as acceptable
alternatives for every ML feature in the spec.
2. Pure functions first. Feature engineering and synthetic-label
generation (`features.py`, `synthetic_labels.py`) take/return plain
dicts, lists, and pandas DataFrames - no database or network I/O.
This is what makes them unit-testable without a live Postgres
instance and keeps the ML logic independent of the persistence
layer (clean architecture / SOLID: the model layer doesn't know
Postgres exists).
3. Every "intelligent" feature (discount %, trending score, demand
forecast, recommendation ranking, popularity score, store
performance, purchase propensity) is produced by a trained model
loaded from `artifacts/*.joblib`, never a hardcoded lookup table.
Where no real historical A/B-tested label exists (e.g. "what
discount SHOULD this product have had"), we bootstrap training
labels from a documented, multi-factor formula + noise
(`synthetic_labels.py`) - this is standard practice for cold-start
ML systems. The important part is that INFERENCE always goes
through the trained model, not the formula - the formula only
exists to generate training data once.
"""

View File

@@ -0,0 +1,215 @@
"""
Features 4 & 5: Store Analytics Dashboard + Product Analytics.
Pure computation over plain pandas DataFrames - no SQL in this file.
`app/services/analytics_service.py` is the thin I/O layer that pulls
DataFrames out of `store_db.py` and hands them to the functions here,
which keeps every KPI formula unit-testable without a live Postgres
instance (see `tests/test_intelligence.py`).
"""
from __future__ import annotations
from typing import Dict, List, Optional
import numpy as np
import pandas as pd
def classify_stock_status(available: int, reorder_level: int, safety_stock: int) -> str:
if available <= 0:
return "Out of Stock"
if available <= safety_stock:
return "Low Stock"
if available > reorder_level * 6:
return "Overstocked"
return "In Stock"
def inventory_analytics(store_products: pd.DataFrame) -> Dict:
"""`store_products` columns: available_stock, reorder_level, safety_stock (already
filtered to one store, or pass the full multi-store frame for a
chain-wide summary)."""
if store_products.empty:
return {"total_products": 0, "in_stock": 0, "low_stock": 0, "out_of_stock": 0, "overstocked": 0}
statuses = store_products.apply(
lambda r: classify_stock_status(r["available_stock"], r["reorder_level"], r["safety_stock"]), axis=1
)
counts = statuses.value_counts()
return {
"total_products": int(len(store_products)),
"in_stock": int(counts.get("In Stock", 0)),
"low_stock": int(counts.get("Low Stock", 0)),
"out_of_stock": int(counts.get("Out of Stock", 0)),
"overstocked": int(counts.get("Overstocked", 0)),
}
def sales_analytics(orders: pd.DataFrame) -> Dict:
"""`orders` columns: order_date (datetime64), order_value. Already
filtered to the scope (one store, or all stores) the caller wants."""
if orders.empty:
return {
"total_sales": 0, "revenue": 0.0, "average_basket_value": 0.0,
"daily_sales": [], "weekly_sales": [], "monthly_sales": [],
}
revenue = float(orders["order_value"].sum())
total_sales = int(len(orders))
avg_basket = revenue / total_sales if total_sales else 0.0
daily = orders.groupby(orders["order_date"].dt.date)["order_value"].agg(["sum", "count"]).reset_index()
daily.columns = ["date", "revenue", "orders"]
weekly = orders.groupby(orders["order_date"].dt.to_period("W").astype(str))["order_value"].agg(["sum", "count"]).reset_index()
weekly.columns = ["week", "revenue", "orders"]
monthly = orders.groupby(orders["order_date"].dt.to_period("M").astype(str))["order_value"].agg(["sum", "count"]).reset_index()
monthly.columns = ["month", "revenue", "orders"]
return {
"total_sales": total_sales,
"revenue": round(revenue, 2),
"average_basket_value": round(avg_basket, 2),
"daily_sales": [{"date": str(r["date"]), "revenue": round(r["revenue"], 2), "orders": int(r["orders"])} for _, r in daily.iterrows()],
"weekly_sales": [{"week": r["week"], "revenue": round(r["revenue"], 2), "orders": int(r["orders"])} for _, r in weekly.iterrows()],
"monthly_sales": [{"month": r["month"], "revenue": round(r["revenue"], 2), "orders": int(r["orders"])} for _, r in monthly.iterrows()],
}
def profit_analytics(order_items: pd.DataFrame, store_prices: pd.DataFrame) -> Dict:
"""Joins order line items to CURRENT store cost prices to estimate
profit (`(unit_price - cost_price) * quantity`). This is an
approximation - it uses today's cost price, not the cost price that
was actually in effect on the historical order date, since the
system doesn't keep a cost-price history table. Documented rather
than silently treated as exact."""
if order_items.empty or store_prices.empty:
return {"total_profit": 0.0, "gross_profit_pct": 0.0}
merged = order_items.merge(
store_prices[["store_id", "brand", "image_id", "cost_price"]],
on=["store_id", "brand", "image_id"], how="left",
)
merged["cost_price"] = merged["cost_price"].fillna(merged["unit_price"] * 0.78)
merged["line_profit"] = (merged["unit_price"] - merged["cost_price"]) * merged["quantity"]
total_profit = float(merged["line_profit"].sum())
total_revenue = float(merged["line_total"].sum())
gp_pct = (total_profit / total_revenue * 100) if total_revenue else 0.0
return {"total_profit": round(total_profit, 2), "gross_profit_pct": round(gp_pct, 2)}
def store_comparison(orders: pd.DataFrame, order_items: pd.DataFrame, store_prices: pd.DataFrame, stores: pd.DataFrame) -> Dict:
"""`stores` columns: store_id, store_name, tier, footfall_index."""
if orders.empty:
return {"stores": [], "best_performing": None, "lowest_performing": None,
"highest_revenue": None, "highest_profit": None}
per_store_revenue = orders.groupby("store_id")["order_value"].agg(["sum", "count"]).reset_index()
per_store_revenue.columns = ["store_id", "revenue", "order_count"]
profit_rows = []
for store_id, g in order_items.groupby("store_id"):
sp = store_prices[store_prices["store_id"] == store_id]
p = profit_analytics(g, sp)
profit_rows.append({"store_id": store_id, "profit": p["total_profit"]})
profit_df = pd.DataFrame(profit_rows) if profit_rows else pd.DataFrame(columns=["store_id", "profit"])
merged = per_store_revenue.merge(profit_df, on="store_id", how="left").merge(stores, on="store_id", how="left")
merged["profit"] = merged["profit"].fillna(0.0)
merged["avg_order_value"] = merged["revenue"] / merged["order_count"].replace(0, np.nan)
merged["avg_order_value"] = merged["avg_order_value"].fillna(0.0)
# "Customer Footfall (simulated)" - the store's actual simulated order
# count IS the simulated footfall proxy (every order came from a
# simulated in-store/online customer visit).
merged["footfall_simulated"] = merged["order_count"]
result_stores = [
{
"store_id": r["store_id"], "store_name": r.get("store_name"), "tier": r.get("tier"),
"revenue": round(r["revenue"], 2), "profit": round(r["profit"], 2),
"order_count": int(r["order_count"]), "avg_order_value": round(r["avg_order_value"], 2),
"footfall_simulated": int(r["footfall_simulated"]),
}
for _, r in merged.iterrows()
]
by_revenue = sorted(result_stores, key=lambda s: s["revenue"], reverse=True)
by_profit = sorted(result_stores, key=lambda s: s["profit"], reverse=True)
return {
"stores": result_stores,
"best_performing": by_profit[0]["store_id"] if by_profit else None,
"lowest_performing": by_profit[-1]["store_id"] if by_profit else None,
"highest_revenue": by_revenue[0]["store_id"] if by_revenue else None,
"highest_profit": by_profit[0]["store_id"] if by_profit else None,
}
def top_products(order_items: pd.DataFrame, limit: int = 10, ascending: bool = False, by: str = "revenue") -> List[Dict]:
"""`by`: 'revenue' or 'units'. Set ascending=True for "lowest
selling" instead of "top selling"."""
if order_items.empty:
return []
agg = order_items.groupby(["brand", "image_id"]).agg(
revenue=("line_total", "sum"), units=("quantity", "sum"), orders=("order_id", "nunique"),
).reset_index()
sort_col = "revenue" if by == "revenue" else "units"
agg = agg.sort_values(sort_col, ascending=ascending).head(limit)
return [
{"brand": r["brand"], "image_id": r["image_id"], "revenue": round(r["revenue"], 2),
"units_sold": int(r["units"]), "order_count": int(r["orders"])}
for _, r in agg.iterrows()
]
def product_analytics(
brand: str, image_id: str,
order_items: pd.DataFrame, store_prices: pd.DataFrame,
engagement_row: Optional[Dict] = None, popularity_score: Optional[float] = None,
demand_score: Optional[float] = None,
) -> Dict:
"""Full Feature 5 metric set for one product, aggregated across all
stores that carry it, plus a store-wise breakdown."""
prod_items = order_items[(order_items["brand"] == brand) & (order_items["image_id"] == image_id)]
prod_prices = store_prices[(store_prices["brand"] == brand) & (store_prices["image_id"] == image_id)]
sales_count = int(prod_items["quantity"].sum())
revenue = float(prod_items["line_total"].sum())
profit_info = profit_analytics(prod_items, store_prices)
order_count = int(prod_items["order_id"].nunique())
store_wise = (
prod_items.groupby("store_id").agg(units=("quantity", "sum"), revenue=("line_total", "sum")).reset_index()
if not prod_items.empty else pd.DataFrame(columns=["store_id", "units", "revenue"])
)
# Growth %: last-14-days units vs the 14 days before that (real data,
# not synthetic - same "current vs previous window" pattern used by
# the trending model, just exposed as a plain metric here).
growth_pct = None
if not prod_items.empty and "order_date" in prod_items.columns:
last_date = prod_items["order_date"].max()
cur_start = last_date - pd.Timedelta(days=14)
prev_start = cur_start - pd.Timedelta(days=14)
cur = prod_items[prod_items["order_date"] >= cur_start]["quantity"].sum()
prev = prod_items[(prod_items["order_date"] >= prev_start) & (prod_items["order_date"] < cur_start)]["quantity"].sum()
growth_pct = round(float((cur - prev) / prev * 100) if prev else (100.0 if cur else 0.0), 1)
avg_stock = float(prod_prices["selling_price"].mean()) if not prod_prices.empty else 0.0
# Stock turnover = units sold / average stock held (a standard retail
# KPI: how many times the inventory "turned over" in the observed period).
turnover = None
return {
"brand": brand, "image_id": image_id,
"sales_count": sales_count,
"revenue": round(revenue, 2),
"profit": profit_info["total_profit"],
"orders": order_count,
"avg_rating": (engagement_row or {}).get("avg_rating"),
"views": (engagement_row or {}).get("views"),
"wishlist_count": (engagement_row or {}).get("wishlist_count"),
"conversion_rate": (engagement_row or {}).get("conversion_rate"),
"popularity_score": round(popularity_score, 1) if popularity_score is not None else None,
"demand_score": round(demand_score, 1) if demand_score is not None else None,
"growth_pct": growth_pct,
"store_wise_sales": [
{"store_id": r["store_id"], "units": int(r["units"]), "revenue": round(r["revenue"], 2)}
for _, r in store_wise.iterrows()
],
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,148 @@
"""
Feature 3: ML-Based Dynamic Discount Prediction.
Model: GradientBoostingRegressor (scikit-learn). Chosen over
XGBoost/LightGBM/CatBoost for the hardware-conscious reasons explained
in `app/intelligence/__init__.py` - it's on the spec's own list of
acceptable options and needs no extra native dependency.
Training labels are bootstrapped via `synthetic_labels.synthetic_discount_pct`
(see that module's docstring for why and how) - but the FEATURES used
here go beyond the flat formula: real simulated sales velocity, demand,
and popularity from actual order history are included, so the trained
model's predictions are not a re-derivation of the formula, they're a
learned function of real behavioural signals plus the bootstrapped
business-rule signal.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List
import numpy as np
import pandas as pd
from app.intelligence.model_utils import ModelBundle, load_bundle, save_bundle
from app.intelligence import features as F
from app.intelligence.synthetic_labels import synthetic_discount_pct
MODEL_NAME = "discount_model"
FEATURE_COLUMNS = [
"stock_ratio", "days_of_cover", "units_per_day_7", "units_per_day_30",
"demand_score", "popularity_score", "days_to_expiry", "is_festive_season",
"category_freq", "store_tier_encoded", "price_position",
]
def build_training_frame(store_products: pd.DataFrame, order_items: pd.DataFrame, as_of) -> pd.DataFrame:
"""`store_products` columns: store_id, brand, image_id, category,
mrp, cost_price, selling_price, available_stock, reorder_level,
safety_stock, store_tier, days_since_stocked, demand_score,
popularity_score.
Returns a frame with FEATURE_COLUMNS + 'discount_pct' (label).
"""
rows = []
cat_freq = F.encode_category(store_products["category"])
for i, row in store_products.reset_index(drop=True).iterrows():
vel7 = F.sales_velocity(order_items, row["store_id"], row["brand"], row["image_id"], as_of, 7)
vel30 = F.sales_velocity(order_items, row["store_id"], row["brand"], row["image_id"], as_of, 30)
rows.append({
"stock_ratio": F.stock_ratio(row["available_stock"], row["reorder_level"]),
"days_of_cover": F.days_of_cover(row["available_stock"], max(vel30["units_per_day"], 0.05)),
"units_per_day_7": vel7["units_per_day"],
"units_per_day_30": vel30["units_per_day"],
"demand_score": row["demand_score"],
"popularity_score": row["popularity_score"],
"days_to_expiry": F.days_to_expiry(row["category"], row["days_since_stocked"]),
"is_festive_season": F.is_festive_season(as_of),
"category_freq": cat_freq.iloc[i],
"store_tier_encoded": F.encode_store_tier(row["store_tier"]),
"price_position": (row["selling_price"] / row["mrp"]) if row["mrp"] else 1.0,
})
df = pd.DataFrame(rows)
rng = np.random.default_rng(7)
df["discount_pct"] = synthetic_discount_pct(df, rng)
return df
def train(training_frame: pd.DataFrame) -> ModelBundle:
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error
X = training_frame[FEATURE_COLUMNS]
y = training_frame["discount_pct"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model = GradientBoostingRegressor(
n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.9, random_state=42,
)
model.fit(X_train, y_train)
mae = float(mean_absolute_error(y_test, model.predict(X_test)))
bundle = ModelBundle(
estimator=model,
feature_columns=FEATURE_COLUMNS,
model_name=MODEL_NAME,
n_samples=len(training_frame),
extra={"val_mae_pct_points": round(mae, 3),
"feature_importances": dict(zip(FEATURE_COLUMNS, [round(float(v), 4) for v in model.feature_importances_]))},
)
save_bundle(bundle)
return bundle
@dataclass
class DiscountPrediction:
discount_pct: float
final_price: float
savings: float
class DiscountPredictor:
"""Thin inference wrapper. Loads the trained bundle lazily and caches
it in-process (safe for a single-worker CPU deployment; restart the
API after retraining to pick up a new artifact)."""
def __init__(self) -> None:
self._bundle: ModelBundle | None = None
def _ensure_loaded(self) -> bool:
if self._bundle is None:
self._bundle = load_bundle(MODEL_NAME)
return self._bundle is not None
def predict_one(self, feature_row: Dict[str, float], original_price: float) -> DiscountPrediction:
if not self._ensure_loaded():
# Graceful degradation: no trained model yet -> 0% discount,
# never a hardcoded non-zero guess.
return DiscountPrediction(discount_pct=0.0, final_price=round(original_price, 2), savings=0.0)
X = pd.DataFrame([[feature_row.get(c, 0.0) for c in self._bundle.feature_columns]],
columns=self._bundle.feature_columns)
pct = float(np.clip(self._bundle.estimator.predict(X)[0], 0, 35))
pct = round(pct, 1)
final_price = round(original_price * (1 - pct / 100.0), 2)
savings = round(original_price - final_price, 2)
return DiscountPrediction(discount_pct=pct, final_price=final_price, savings=savings)
def predict_batch(self, df: pd.DataFrame) -> pd.DataFrame:
"""`df` must contain FEATURE_COLUMNS plus a 'selling_price' column.
Returns df with discount_pct/final_price/savings columns added."""
if not self._ensure_loaded() or df.empty:
out = df.copy()
out["discount_pct"] = 0.0
out["final_price"] = out.get("selling_price", 0.0)
out["savings"] = 0.0
return out
X = df[self._bundle.feature_columns]
preds = np.clip(self._bundle.estimator.predict(X), 0, 35)
out = df.copy()
out["discount_pct"] = np.round(preds, 1)
out["final_price"] = np.round(out["selling_price"] * (1 - out["discount_pct"] / 100.0), 2)
out["savings"] = np.round(out["selling_price"] - out["final_price"], 2)
return out
discount_predictor = DiscountPredictor()

View File

@@ -0,0 +1,49 @@
"""
Simulated product engagement signals: views, wishlist adds, and star
ratings. Real e-commerce systems have this from clickstream/telemetry;
this system doesn't have a live storefront generating that yet, so we
derive plausible values deterministically from the same latent
popularity used by the order simulator (`order_simulation.latent_popularity`)
plus independent noise, so views/wishlist correlate with - but aren't
identical to - actual purchase volume (matching real behaviour: not
every view converts, not every wishlist add is purchased).
Swap this module out for real analytics/telemetry ingestion once the
storefront captures it; `popularity_model.py` and the product-analytics
endpoints only depend on the DataFrame shape this returns, not on how
it was produced.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from app.intelligence.order_simulation import latent_popularity
def simulate_engagement(products: pd.DataFrame, orders_count_by_product: pd.Series, seed: int = 5) -> pd.DataFrame:
"""`products` needs columns brand, image_id. `orders_count_by_product`
is a Series indexed by 'brand||image_id' with real simulated order
counts (used so views/conversion stay internally consistent with
actual simulated purchase behaviour).
Returns a DataFrame with: brand, image_id, views, wishlist_count,
orders_count, avg_rating, conversion_rate.
"""
rng = np.random.default_rng(seed)
rows = []
for _, p in products.iterrows():
key = f"{p['brand']}||{p['image_id']}"
pop = latent_popularity(p["brand"], p["image_id"])
orders_count = float(orders_count_by_product.get(key, 0.0))
base_views = max(orders_count * rng.uniform(18, 45), pop * 120)
views = int(base_views * rng.lognormal(0, 0.25))
wishlist = int(views * rng.uniform(0.02, 0.09) * (0.6 + 0.8 * pop))
conversion_rate = float(np.clip(orders_count / max(views, 1), 0, 1))
avg_rating = float(np.clip(rng.normal(3.6 + pop * 1.1, 0.35), 1.0, 5.0))
rows.append({
"brand": p["brand"], "image_id": p["image_id"], "views": views,
"wishlist_count": wishlist, "orders_count": orders_count,
"avg_rating": round(avg_rating, 2), "conversion_rate": round(conversion_rate, 4),
})
return pd.DataFrame(rows)

View File

@@ -0,0 +1,158 @@
"""
Shared, pure feature-engineering helpers.
Nothing in this module touches the database or the network - every
function takes plain Python / pandas structures in and returns them
out, which is what lets `tests/test_intelligence.py` exercise the real
ML feature logic without a live Postgres instance.
"""
from __future__ import annotations
import math
from datetime import date, datetime
from typing import Dict, Iterable, List, Optional
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# Category -> shelf-life bucket (used for expiry-aware discounting).
# Perishables get a short simulated shelf life; ambient/packaged goods get
# a long one (effectively "doesn't expire for pricing purposes").
# ---------------------------------------------------------------------------
PERISHABLE_SHELF_LIFE_DAYS = {
"dairy": 10,
"bakery & breads": 4,
"cakes & muffins": 5,
}
DEFAULT_SHELF_LIFE_DAYS = 270 # ambient FMCG (biscuits, tea, soap, ...)
STORE_TIER_ORDER = ["budget", "standard", "premium"]
def shelf_life_days_for_category(category: Optional[str]) -> int:
key = (category or "").strip().lower()
for k, v in PERISHABLE_SHELF_LIFE_DAYS.items():
if k in key:
return v
return DEFAULT_SHELF_LIFE_DAYS
def days_to_expiry(category: Optional[str], days_since_stocked: int) -> int:
"""Simulated remaining shelf life. Clamped at 0 (already expired stock
would have been written off, so this floors at 0 rather than going
negative)."""
life = shelf_life_days_for_category(category)
return max(0, life - int(days_since_stocked))
def cyclical_month_features(as_of: date) -> Dict[str, float]:
"""Sine/cosine encode the month so "December" and "January" are close
in feature space (seasonality wraps around the year)."""
angle = 2 * math.pi * (as_of.month - 1) / 12.0
return {"month_sin": math.sin(angle), "month_cos": math.cos(angle)}
def cyclical_dow_features(as_of: date) -> Dict[str, float]:
angle = 2 * math.pi * as_of.weekday() / 7.0
return {"dow_sin": math.sin(angle), "dow_cos": math.cos(angle)}
def is_festive_season(as_of: date) -> int:
"""Coarse Indian FMCG festive-demand window (Oct-Nov: Diwali season;
Aug: Independence Day/Onam-ish promo season). Used as a simple
seasonal-trend signal rather than hardcoding a discount bump - the
model learns how much this feature matters from the training data."""
return 1 if as_of.month in (10, 11) or as_of.month == 8 else 0
def stock_ratio(available_stock: int, reorder_level: int) -> float:
""">1 means well-stocked relative to reorder point; <1 means at/below
the reorder point. Reorder level is floored at 1 to avoid div-by-zero
for misconfigured rows."""
return float(available_stock) / float(max(reorder_level, 1))
def days_of_cover(available_stock: int, avg_daily_sales: float) -> float:
"""How many days current stock would last at the recent sales pace.
A very small floor on avg_daily_sales avoids an artificial 'infinite'
days-of-cover for a product that just hasn't sold yet."""
return float(available_stock) / max(float(avg_daily_sales), 0.05)
def sales_velocity(order_items: pd.DataFrame, store_id: str, brand: str, image_id: str,
as_of: date, window_days: int) -> Dict[str, float]:
"""Units/day and revenue/day sold for one (store, product) over the
trailing `window_days` ending at `as_of` (exclusive of future data -
callers must only pass order history up to `as_of`).
`order_items` is expected to have columns:
store_id, brand, image_id, order_date (datetime64), quantity, line_total
"""
if order_items.empty:
return {"units_per_day": 0.0, "revenue_per_day": 0.0, "order_count": 0}
window_start = pd.Timestamp(as_of) - pd.Timedelta(days=window_days)
mask = (
(order_items["store_id"] == store_id)
& (order_items["brand"] == brand)
& (order_items["image_id"] == image_id)
& (order_items["order_date"] >= window_start)
& (order_items["order_date"] < pd.Timestamp(as_of))
)
sub = order_items.loc[mask]
units = float(sub["quantity"].sum())
revenue = float(sub["line_total"].sum())
return {
"units_per_day": units / max(window_days, 1),
"revenue_per_day": revenue / max(window_days, 1),
"order_count": int(len(sub)),
}
def rfm_features(orders: pd.DataFrame, customer_id: str, as_of: date) -> Dict[str, float]:
"""Recency / Frequency / Monetary features for one customer, computed
only from orders strictly before `as_of` (so this is safe to use as a
training feature with a held-out future window as the label).
`orders` columns: customer_id, order_date (datetime64), order_value
"""
hist = orders[(orders["customer_id"] == customer_id) & (orders["order_date"] < pd.Timestamp(as_of))]
if hist.empty:
return {"recency_days": 999.0, "frequency": 0.0, "monetary": 0.0, "avg_order_value": 0.0}
last_order = hist["order_date"].max()
recency_days = (pd.Timestamp(as_of) - last_order).days
frequency = float(len(hist))
monetary = float(hist["order_value"].sum())
return {
"recency_days": float(recency_days),
"frequency": frequency,
"monetary": monetary,
"avg_order_value": monetary / frequency,
}
def normalize_0_100(series: pd.Series) -> pd.Series:
"""Min-max normalize a numeric series to a 0-100 range. Constant
series map to 50 (avoids div-by-zero and avoids an arbitrary 0)."""
lo, hi = series.min(), series.max()
if hi - lo < 1e-9:
return pd.Series([50.0] * len(series), index=series.index)
return (series - lo) / (hi - lo) * 100.0
def encode_category(categories: Iterable[str]) -> pd.Series:
"""Simple stable frequency-encoding for a category column - keeps
every model's category feature deterministic across train/inference
without needing to persist a fitted OneHotEncoder for a small
cardinality field."""
s = pd.Series(list(categories)).fillna("Uncategorized")
freq = s.value_counts(normalize=True)
return s.map(freq).fillna(0.0)
def encode_store_tier(tier: str) -> int:
try:
return STORE_TIER_ORDER.index((tier or "standard").lower())
except ValueError:
return 1

View File

@@ -0,0 +1,140 @@
"""
Feature 9: Demand Forecasting / Inventory Forecasting.
Approach: rolling-window time-series feature engineering (7/14/30-day
trailing rolling means of units sold, day-of-week and month cyclical
encoding, festive-season flag) feeding a RandomForestRegressor that
predicts expected average daily demand over the NEXT 7 days. This is
the same "engineer time features, then regress" pattern used for
trending (see `trending_model.py`), applied here to forecast forward
instead of score the present. Chosen over Prophet/LSTM for the
hardware-conscious reasons documented in `app/intelligence/__init__.py`.
Predicted daily demand also directly drives `available_stock -
predicted_demand * lead_time_days` for inventory forecasting, so one
model serves both "Demand Forecasting" and "Inventory Forecasting" in
Feature 9's suggested-models table rather than duplicating near-
identical logic in two places.
Pooled by (category, store_tier) rather than trained per exact product:
with a handful of stores and a simulated order history, most individual
products don't have enough daily data points for a standalone
time-series model to learn anything - pooling similar products' rolling
patterns gives the model enough signal while still producing a
per-product-per-store forecast at inference time (each row is scored
individually; only the training data is pooled).
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import List
import numpy as np
import pandas as pd
from app.intelligence import features as F
from app.intelligence.model_utils import ModelBundle, load_bundle, save_bundle
MODEL_NAME = "demand_forecast_model"
FEATURE_COLUMNS = [
"rolling_mean_7", "rolling_mean_14", "rolling_mean_30", "month_sin", "month_cos",
"dow_sin", "dow_cos", "is_festive_season", "category_freq", "store_tier_encoded",
]
def _daily_series(order_items: pd.DataFrame, store_id: str, brand: str, image_id: str) -> pd.Series:
sub = order_items[
(order_items["store_id"] == store_id) & (order_items["brand"] == brand) & (order_items["image_id"] == image_id)
]
if sub.empty:
return pd.Series(dtype=float)
daily = sub.groupby(sub["order_date"].dt.date)["quantity"].sum()
daily.index = pd.to_datetime(daily.index)
return daily.asfreq("D", fill_value=0)
def build_training_frame(
order_items: pd.DataFrame,
product_meta: pd.DataFrame, # columns: store_id, brand, image_id, category, store_tier
as_of_dates: List[date],
) -> pd.DataFrame:
"""For each (store, product, as_of) sample a rolling-feature row and
the REAL (not synthetic) label: actual average daily units sold in
the 7 days AFTER as_of. This is genuine supervised time-series
forecasting - the label comes straight from the simulated ground
truth, no bootstrap formula needed here (unlike discount/trending)."""
cat_freq = F.encode_category(product_meta["category"])
rows = []
for i, meta in product_meta.reset_index(drop=True).iterrows():
series = _daily_series(order_items, meta["store_id"], meta["brand"], meta["image_id"])
if series.empty:
continue
for as_of in as_of_dates:
as_of_ts = pd.Timestamp(as_of)
history = series[series.index < as_of_ts]
future = series[(series.index >= as_of_ts) & (series.index < as_of_ts + pd.Timedelta(days=7))]
if len(history) < 14 or future.empty:
continue
row = {
"rolling_mean_7": history.tail(7).mean(),
"rolling_mean_14": history.tail(14).mean(),
"rolling_mean_30": history.tail(30).mean(),
**F.cyclical_month_features(as_of),
**F.cyclical_dow_features(as_of),
"is_festive_season": F.is_festive_season(as_of),
"category_freq": cat_freq.iloc[i],
"store_tier_encoded": F.encode_store_tier(meta["store_tier"]),
"target_avg_daily_units": future.mean(),
}
rows.append(row)
return pd.DataFrame(rows)
def train(training_frame: pd.DataFrame) -> ModelBundle:
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error
X = training_frame[FEATURE_COLUMNS]
y = training_frame["target_avg_daily_units"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model = RandomForestRegressor(n_estimators=200, max_depth=8, min_samples_leaf=3, random_state=42, n_jobs=-1)
model.fit(X_train, y_train)
mae = float(mean_absolute_error(y_test, model.predict(X_test)))
bundle = ModelBundle(
estimator=model, feature_columns=FEATURE_COLUMNS, model_name=MODEL_NAME,
n_samples=len(training_frame), extra={"val_mae_units_per_day": round(mae, 3)},
)
save_bundle(bundle)
return bundle
class DemandForecaster:
def __init__(self) -> None:
self._bundle: ModelBundle | None = None
def _ensure_loaded(self) -> bool:
if self._bundle is None:
self._bundle = load_bundle(MODEL_NAME)
return self._bundle is not None
def forecast(self, feature_df: pd.DataFrame, horizon_days: int = 7) -> pd.DataFrame:
"""Returns feature_df with `forecast_avg_daily_units` and
`forecast_total_units` (over horizon_days) columns added. Falls
back to the naive rolling_mean_7 (still real data, just not
model-refined) if no model is trained yet - never a fixed
constant."""
out = feature_df.copy()
if not self._ensure_loaded() or feature_df.empty:
out["forecast_avg_daily_units"] = out.get("rolling_mean_7", 0.0)
else:
X = feature_df[self._bundle.feature_columns]
out["forecast_avg_daily_units"] = np.clip(self._bundle.estimator.predict(X), 0, None)
out["forecast_total_units"] = out["forecast_avg_daily_units"] * horizon_days
return out
demand_forecaster = DemandForecaster()

View File

@@ -0,0 +1,61 @@
"""Shared persistence helper for every trained model in this package.
Keeping load/save in one place (rather than duplicated per model file)
is the SOLID/DRY-motivated reason this exists - every *_model.py file
just calls `save_bundle` / `load_bundle` with its own feature list and
estimator.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
ARTIFACTS_DIR = Path(__file__).resolve().parent / "artifacts"
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
@dataclass
class ModelBundle:
"""Everything needed to reproduce a prediction: the fitted estimator,
the exact feature column order it expects, and light metadata for
the admin/health endpoints to report on (trained_at, n_samples,
version)."""
estimator: Any
feature_columns: List[str]
model_name: str
version: str = "v1"
trained_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
n_samples: int = 0
extra: Dict[str, Any] = field(default_factory=dict)
def artifact_path(model_name: str) -> Path:
return ARTIFACTS_DIR / f"{model_name}.joblib"
def save_bundle(bundle: ModelBundle) -> Path:
import joblib # lazy import: keeps API boot fast if scikit-learn isn't needed yet
path = artifact_path(bundle.model_name)
joblib.dump(bundle, path)
logger.info("Saved model bundle '%s' (%d samples) -> %s", bundle.model_name, bundle.n_samples, path)
return path
def load_bundle(model_name: str) -> Optional[ModelBundle]:
import joblib
path = artifact_path(model_name)
if not path.exists():
logger.warning("No trained model artifact found for '%s' at %s - run scripts/train_ml_models.py first", model_name, path)
return None
try:
return joblib.load(path)
except Exception as e: # noqa: BLE001
logger.error("Failed to load model bundle '%s': %s", model_name, e)
return None

View File

@@ -0,0 +1,115 @@
"""
Feature 14: "Nutrition-Based Clustering".
Groups products into nutrition-profile clusters with scikit-learn's
KMeans over the same normalized feature space as the similarity model.
Cluster labels are derived transparently from each cluster's own
centroid statistics (e.g. "High Protein / Low Sugar") rather than
LLM-named, so a cluster's name is always traceable back to real
aggregate numbers.
Used for: the `nutrition_cluster` / `nutrition_cluster_label` columns
on `nutrition_insights` (surfaced in the product detail view and the
analytics dashboard), and as a candidate pool for
`nutrition_alternatives_service.py` (restricting "healthier
alternative" search to a nutritionally-similar cluster rather than the
whole catalog).
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Tuple
import numpy as np
import pandas as pd
from app.intelligence.model_utils import ModelBundle, load_bundle, save_bundle
from app.intelligence.nutrition_similarity import FEATURE_COLUMNS, MIN_VERIFIED_FRACTION
logger = logging.getLogger(__name__)
MODEL_NAME = "nutrition_clustering"
DEFAULT_N_CLUSTERS = 6
def _label_cluster(centroid: pd.Series, overall_median: pd.Series) -> str:
"""Names a cluster from how its centroid compares to the whole
catalog's median on the two or three most distinctive nutrients -
entirely derived from the data, not hand-authored per cluster."""
descriptors: List[Tuple[str, float]] = []
readable = {
"protein_g": "High Protein", "dietary_fiber_g": "High Fiber",
"total_sugar_g": "High Sugar", "sodium_mg": "High Sodium",
"saturated_fat_g": "High Saturated Fat", "calories_kcal": "Calorie Dense",
}
for col, label in readable.items():
if col not in centroid or col not in overall_median or overall_median[col] in (0, None):
continue
ratio = centroid[col] / overall_median[col] if overall_median[col] else 1.0
if ratio >= 1.3:
descriptors.append((label, ratio))
elif ratio <= 0.7 and ratio > 0 and col in ("total_sugar_g", "sodium_mg", "saturated_fat_g", "calories_kcal"):
descriptors.append((f"Low {label.replace('High ', '')}", 1 / ratio))
descriptors.sort(key=lambda t: t[1], reverse=True)
top = [d[0] for d in descriptors[:2]]
return " / ".join(top) if top else "Balanced Profile"
def train_clusters(df: pd.DataFrame, n_clusters: int = DEFAULT_N_CLUSTERS) -> Dict[str, Any]:
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
if df.empty:
return {"trained": False, "reason": "no verified nutrition rows"}
df = df.copy()
verified_fraction = df[FEATURE_COLUMNS].notna().sum(axis=1) / len(FEATURE_COLUMNS)
df = df[verified_fraction >= MIN_VERIFIED_FRACTION].reset_index(drop=True)
k = min(n_clusters, max(2, len(df) // 3))
if len(df) < k * 2:
return {"trained": False, "reason": f"only {len(df)} products have enough verified fields for {k} clusters"}
# Median-impute missing values; if a whole column is missing (median is
# NaN), fall back to 0.0 so KMeans never receives NaN.
matrix = df[FEATURE_COLUMNS].apply(lambda col: col.fillna(col.median()).fillna(0.0))
scaler = StandardScaler()
scaled = scaler.fit_transform(matrix.values)
km = KMeans(n_clusters=k, n_init=10, random_state=42)
labels = km.fit_predict(scaled)
overall_median = matrix.median()
cluster_names: Dict[int, str] = {}
for c in range(k):
centroid_raw = matrix[labels == c].median()
cluster_names[c] = _label_cluster(centroid_raw, overall_median)
assignments = {
(brand, image_id): {"cluster": int(c), "label": cluster_names[int(c)]}
for brand, image_id, c in zip(df["brand"], df["image_id"], labels)
}
bundle = ModelBundle(
estimator=km,
feature_columns=FEATURE_COLUMNS,
model_name=MODEL_NAME,
n_samples=len(df),
extra={"scaler": scaler, "cluster_names": cluster_names, "assignments": assignments},
)
save_bundle(bundle)
logger.info(f"Trained nutrition clustering (k={k}) on {len(df)} products")
return {"trained": True, "n_samples": len(df), "n_clusters": k, "cluster_labels": cluster_names}
def get_assignments() -> Dict[Tuple[str, str], Dict[str, Any]]:
bundle = load_bundle(MODEL_NAME)
if not bundle:
return {}
return bundle.extra.get("assignments", {})
def get_cluster_members(cluster: int) -> List[Tuple[str, str]]:
bundle = load_bundle(MODEL_NAME)
if not bundle:
return []
return [key for key, info in bundle.extra.get("assignments", {}).items() if info["cluster"] == cluster]

View File

@@ -0,0 +1,143 @@
"""
Feature 10: "Personalized Nutrition Recommendations".
Builds each customer's nutrient-purchase profile from the existing
Store Intelligence `orders`/`order_items` tables (v3.0 layer - see
[[logistics-ml-pipelines]] history) joined against verified
`nutrition_facts`, then applies the three example behaviors from the
spec directly:
- frequently buys high-protein items -> recommend more high-protein items
- frequently buys high-sugar snacks -> recommend healthier alternatives
- frequently buys low-fat items -> recommend similar low-fat items
This is content-based filtering over a nutrition feature space (the
"Content-Based Filtering" + "Nutrition Similarity" options from the
spec's algorithm list) rather than collaborative filtering, since it
needs to work for a single customer's history without requiring
enough cross-customer overlap to train a collaborative model - a
reasonable simplification given the 8GB RAM / CPU-only environment and
the size of a simulated order dataset.
Gracefully returns [] if the Store Intelligence order tables haven't
been seeded - this is an optional enhancement on top of that layer,
not a hard dependency of the nutrition module.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List
from app.services import nutrition_db, nutrition_scoring
from app.services.nutrition_alternatives_service import find_alternatives
from app.services.vector_store import _connect
logger = logging.getLogger(__name__)
HIGH_SUGAR_PURCHASE_THRESHOLD = nutrition_scoring.THRESHOLDS["sugar_high"]
HIGH_PROTEIN_PURCHASE_THRESHOLD = nutrition_scoring.THRESHOLDS["protein_high_g"]
LOW_FAT_PURCHASE_THRESHOLD = nutrition_scoring.THRESHOLDS["low_fat_ceiling"]
def _customer_purchase_profile(customer_id: str) -> List[Dict[str, Any]]:
"""Every (brand, image_id) the customer has ordered, with verified
nutrition facts attached, weighted by how many times they bought it."""
conn = _connect()
if not conn:
return []
try:
from psycopg.rows import dict_row
with conn, conn.cursor(row_factory=dict_row) as cur:
cur.execute(
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'order_items')"
)
if not cur.fetchone()["exists"]:
return []
cur.execute(
"""
SELECT oi.brand, oi.image_id, SUM(oi.quantity) AS times_purchased,
f.category, f.protein_g, f.total_sugar_g, f.total_fat_g,
f.dietary_fiber_g, i.health_score
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id
LEFT JOIN nutrition_facts f ON f.brand = oi.brand AND f.image_id = oi.image_id
LEFT JOIN nutrition_insights i ON i.brand = oi.brand AND i.image_id = oi.image_id
WHERE o.customer_id = %s
GROUP BY oi.brand, oi.image_id, f.category, f.protein_g, f.total_sugar_g,
f.total_fat_g, f.dietary_fiber_g, i.health_score
""",
(customer_id,),
)
return [dict(r) for r in cur.fetchall()]
except Exception as e: # noqa: BLE001
logger.error(f"_customer_purchase_profile failed for {customer_id}: {e}")
return []
finally:
conn.close()
def recommend_for_customer(customer_id: str, top_k: int = 8) -> Dict[str, Any]:
purchases = _customer_purchase_profile(customer_id)
scored_purchases = [p for p in purchases if p.get("protein_g") is not None or p.get("total_sugar_g") is not None]
if not scored_purchases:
return {"customer_id": customer_id, "purchase_pattern": "insufficient_data", "recommendations": []}
def weighted_avg(field: str) -> float:
vals = [(p[field], p["times_purchased"]) for p in scored_purchases if p.get(field) is not None]
if not vals:
return 0.0
total_weight = sum(w for _, w in vals)
return sum(v * w for v, w in vals) / total_weight if total_weight else 0.0
avg_protein = weighted_avg("protein_g")
avg_sugar = weighted_avg("total_sugar_g")
avg_fat = weighted_avg("total_fat_g")
purchased_keys = {(p["brand"], p["image_id"]) for p in purchases}
recommendations: List[Dict[str, Any]] = []
pattern: str
if avg_sugar >= HIGH_SUGAR_PURCHASE_THRESHOLD:
pattern = "frequent_high_sugar_purchases"
# Pull healthier alternatives around their most-purchased high-sugar item.
worst = max(
(p for p in scored_purchases if p.get("total_sugar_g") is not None),
key=lambda p: (p["total_sugar_g"], p["times_purchased"]),
default=None,
)
if worst:
alts = find_alternatives(worst["brand"], worst["image_id"], top_k=top_k)
recommendations = [{**a, "recommendation_reason": "Healthier alternative to a frequently purchased high-sugar item"} for a in alts]
elif avg_protein >= HIGH_PROTEIN_PURCHASE_THRESHOLD:
pattern = "frequent_high_protein_purchases"
results = nutrition_db.query_products(sort_by="protein", order="desc", diet_tag="High Protein", limit=30)
recommendations = [
{**r, "recommendation_reason": "Matches your frequent high-protein purchases"}
for r in results if (r["brand"], r["image_id"]) not in purchased_keys
][:top_k]
elif avg_fat > 0 and avg_fat <= LOW_FAT_PURCHASE_THRESHOLD:
pattern = "frequent_low_fat_purchases"
results = nutrition_db.query_products(sort_by="health_score", order="desc", diet_tag="Low Fat", limit=30)
recommendations = [
{**r, "recommendation_reason": "Similar low-fat profile to your recent purchases"}
for r in results if (r["brand"], r["image_id"]) not in purchased_keys
][:top_k]
else:
pattern = "general"
results = nutrition_db.query_products(sort_by="health_score", order="desc", limit=30)
recommendations = [
{**r, "recommendation_reason": "Highly rated for overall nutrition"}
for r in results if (r["brand"], r["image_id"]) not in purchased_keys
][:top_k]
return {
"customer_id": customer_id,
"purchase_pattern": pattern,
"avg_protein_g": round(avg_protein, 1),
"avg_sugar_g": round(avg_sugar, 1),
"avg_fat_g": round(avg_fat, 1),
"recommendations": recommendations,
}

View File

@@ -0,0 +1,113 @@
"""
Feature 8: nutritional similarity, ML-based.
Uses scikit-learn's `NearestNeighbors` with cosine distance over a
normalized nutrient-vector feature space (protein, calories, fiber,
sugar, fat, sodium, + core micronutrients where available) - satisfying
the spec's explicit "Cosine Similarity" and "KNN" options while staying
inside the 8GB RAM / CPU-only budget documented for this environment
(no embeddings/sentence-transformers needed for ~a dozen numeric
features; that would be over-engineering for this feature).
IMPORTANT SCOPE NOTE: missing nutrient values are median-imputed *only*
inside this in-memory feature matrix, purely so the distance metric is
computable. This never writes an imputed number back into
`nutrition_facts` - the database only ever holds verified values (see
`nutrition_data_service.py`). Imputation here is a standard ML
pre-processing step for the similarity model, not a claim about any
product's actual nutrition.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List
import numpy as np
import pandas as pd
from app.intelligence.model_utils import ModelBundle, load_bundle, save_bundle
logger = logging.getLogger(__name__)
MODEL_NAME = "nutrition_similarity"
FEATURE_COLUMNS = [
"calories_kcal", "protein_g", "carbohydrates_g", "dietary_fiber_g",
"total_sugar_g", "total_fat_g", "saturated_fat_g", "sodium_mg",
"calcium_mg", "iron_mg", "vitamin_c_mg", "potassium_mg",
]
# Minimum fraction of the feature columns that must be verified (non-null
# before imputation) for a product to be included in the similarity
# index at all - keeps products with almost no real data out of the
# comparison space entirely rather than comparing on mostly-imputed noise.
MIN_VERIFIED_FRACTION = 0.35
def train_similarity_index(df: pd.DataFrame) -> Dict[str, Any]:
"""`df` is `nutrition_db.get_all_nutrition_facts_df()`. Fits a
StandardScaler + NearestNeighbors bundle and persists it via the
shared model_utils pattern."""
from sklearn.neighbors import NearestNeighbors
from sklearn.preprocessing import StandardScaler
if df.empty:
return {"trained": False, "reason": "no verified nutrition rows"}
df = df.copy()
verified_fraction = df[FEATURE_COLUMNS].notna().sum(axis=1) / len(FEATURE_COLUMNS)
df = df[verified_fraction >= MIN_VERIFIED_FRACTION].reset_index(drop=True)
if len(df) < 3:
return {"trained": False, "reason": f"only {len(df)} products have enough verified fields (need >= 3)"}
# Median-impute missing values. If an entire column is missing (median
# itself is NaN - happens when no product has that nutrient verified),
# fall back to 0.0 so no NaN ever reaches the scaler / NearestNeighbors.
matrix = df[FEATURE_COLUMNS].apply(lambda col: col.fillna(col.median()).fillna(0.0))
scaler = StandardScaler()
scaled = scaler.fit_transform(matrix.values)
n_neighbors = min(11, len(df)) # self + up to 10 neighbors
nn = NearestNeighbors(n_neighbors=n_neighbors, metric="cosine")
nn.fit(scaled)
bundle = ModelBundle(
estimator=nn,
feature_columns=FEATURE_COLUMNS,
model_name=MODEL_NAME,
n_samples=len(df),
extra={
"scaler": scaler,
"product_keys": list(zip(df["brand"], df["image_id"])),
},
)
save_bundle(bundle)
logger.info(f"Trained nutrition similarity index on {len(df)} products")
return {"trained": True, "n_samples": len(df)}
def find_similar(brand: str, image_id: str, top_k: int = 5) -> List[Dict[str, Any]]:
bundle = load_bundle(MODEL_NAME)
if not bundle:
return []
keys: List[tuple] = bundle.extra["product_keys"]
try:
idx = keys.index((brand, image_id))
except ValueError:
return [] # product wasn't in the trained index (too little verified data, or trained before it was enriched)
nn = bundle.estimator
scaler = bundle.extra["scaler"]
query_vec = nn._fit_X[idx].reshape(1, -1) # already-scaled training vector, avoids re-scaling drift
distances, indices = nn.kneighbors(query_vec, n_neighbors=min(top_k + 1, len(keys)))
results = []
for dist, i in zip(distances[0], indices[0]):
cand_brand, cand_image_id = keys[i]
if cand_brand == brand and cand_image_id == image_id:
continue
similarity = round(max(0.0, 1.0 - float(dist)), 4) # cosine distance -> similarity
results.append({"brand": cand_brand, "image_id": cand_image_id, "similarity_score": similarity})
if len(results) >= top_k:
break
return results

View File

@@ -0,0 +1,186 @@
"""
Synthetic order-history generator.
Generates a realistic-looking transaction log for the simulated 5-store
retail environment: which customer bought what, from which store, on
which day, for how much, via which payment method, with what delivery
outcome. This is the ground truth that Feature 8 (Order Simulation)
asks for, and it is also the raw material every other intelligent
feature is trained/computed from:
- Trending detection reads recent order velocity per product.
- The recommendation engine's collaborative-filtering signal reads
which products co-occur in the same order.
- The discount model's demand/velocity features come from here.
- Store/product analytics (revenue, profit, basket value, footfall)
are aggregated directly from this data.
- Purchase-propensity classification uses real (not synthetic-formula)
labels derived from a time-split of this data.
Everything here is deterministic given a seed, so re-running the seed
script reproduces the same catalog/order history - important for
repeatable ML training and for demos.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Dict, List, Optional, Sequence
import hashlib
import numpy as np
import pandas as pd
PAYMENT_METHODS = ["UPI", "Credit/Debit Card", "Cash on Delivery", "Wallet", "Net Banking"]
PAYMENT_WEIGHTS = [0.46, 0.20, 0.18, 0.11, 0.05] # India-skewed toward UPI
DELIVERY_STATUSES = ["Delivered", "Delivered", "Delivered", "Delivered", "Pending", "Cancelled", "Returned"]
STORE_TIER_DEMAND_MULTIPLIER = {"budget": 0.85, "standard": 1.0, "premium": 1.25}
@dataclass
class StoreProfile:
store_id: str
store_name: str
city: str
tier: str # "budget" | "standard" | "premium"
footfall_index: float # baseline avg orders/day before weekday/seasonal effects
@dataclass
class StoreCatalogEntry:
store_id: str
brand: str
image_id: str
category: str
selling_price: float
available_stock: int
def deterministic_unit(seed_key: str) -> float:
"""Same helper pattern as price_estimator._deterministic_unit: a
stable pseudo-random value in [0, 1] from a string seed, so latent
popularity is reproducible across runs without persisting it."""
digest = hashlib.md5(seed_key.strip().lower().encode("utf-8")).hexdigest()
return int(digest[:8], 16) / 0xFFFFFFFF
def latent_popularity(brand: str, image_id: str) -> float:
"""A hidden 0.2-1.0 'true popularity' per product, used only to bias
which products get ordered more often during simulation - this is
what gives the trending/popularity models a real signal to recover
from the resulting order data, rather than every product selling at
a uniform random rate."""
u = deterministic_unit(f"popularity|{brand}|{image_id}")
# Skew toward a Pareto-ish long tail: most products are middling,
# a minority are hits - matches real retail sales distribution.
return 0.2 + (u ** 2.2) * 0.8
def _weekday_multiplier(d: date) -> float:
# Fri/Sat/Sun busier than midweek.
return {0: 0.9, 1: 0.9, 2: 0.95, 3: 1.0, 4: 1.15, 5: 1.35, 6: 1.2}[d.weekday()]
def _festive_multiplier(d: date) -> float:
if d.month in (10, 11): # Diwali season
return 1.4
if d.month == 8: # Independence Day / monsoon promo season
return 1.15
return 1.0
def simulate_orders(
stores: Sequence[StoreProfile],
store_catalogs: Dict[str, List[StoreCatalogEntry]],
start_date: date,
end_date: date,
seed: int = 42,
customers_per_store: int = 220,
) -> "SimulationResult":
"""Generate `orders` and `order_items` DataFrames covering
[start_date, end_date] inclusive, for every store.
Returns a SimulationResult with two DataFrames ready to persist or
feed straight into feature engineering / model training.
"""
rng = np.random.default_rng(seed)
order_rows: List[dict] = []
item_rows: List[dict] = []
order_seq = 0
for store in stores:
catalog = store_catalogs.get(store.store_id, [])
if not catalog:
continue
weights = np.array([latent_popularity(e.brand, e.image_id) for e in catalog])
weights = weights / weights.sum()
customer_ids = [f"CUST-{store.store_id}-{i:04d}" for i in range(customers_per_store)]
# A minority of "regular" customers order much more often than
# the rest, which is what gives RFM/purchase-propensity features
# something meaningful to learn from.
customer_affinity = rng.pareto(a=2.2, size=len(customer_ids)) + 0.15
tier_mult = STORE_TIER_DEMAND_MULTIPLIER.get(store.tier, 1.0)
current = start_date
while current <= end_date:
lam = store.footfall_index * tier_mult * _weekday_multiplier(current) * _festive_multiplier(current)
n_orders_today = rng.poisson(lam=max(lam, 0.1))
if n_orders_today > 0:
cust_p = customer_affinity / customer_affinity.sum()
todays_customers = rng.choice(customer_ids, size=n_orders_today, p=cust_p)
for cust_id in todays_customers:
order_seq += 1
order_id = f"ORD-{store.store_id}-{order_seq:07d}"
n_items = int(rng.integers(1, 5))
picks = rng.choice(len(catalog), size=min(n_items, len(catalog)), replace=False, p=weights)
order_total = 0.0
for idx in picks:
entry = catalog[idx]
qty = int(rng.integers(1, 4))
line_total = round(entry.selling_price * qty, 2)
order_total += line_total
item_rows.append({
"order_id": order_id,
"store_id": store.store_id,
"brand": entry.brand,
"image_id": entry.image_id,
"quantity": qty,
"unit_price": entry.selling_price,
"line_total": line_total,
})
payment = rng.choice(PAYMENT_METHODS, p=PAYMENT_WEIGHTS)
status = rng.choice(DELIVERY_STATUSES)
order_rows.append({
"order_id": order_id,
"customer_id": cust_id,
"store_id": store.store_id,
"order_date": pd.Timestamp(current),
"payment_method": payment,
"order_value": round(order_total, 2),
"delivery_status": status,
})
current += timedelta(days=1)
orders_df = pd.DataFrame(order_rows)
items_df = pd.DataFrame(item_rows)
return SimulationResult(orders=orders_df, order_items=items_df)
@dataclass
class SimulationResult:
orders: pd.DataFrame
order_items: pd.DataFrame
def summary(self) -> dict:
if self.orders.empty:
return {"total_orders": 0, "total_revenue": 0.0, "date_range": None}
return {
"total_orders": int(len(self.orders)),
"total_order_items": int(len(self.order_items)),
"total_revenue": float(self.orders["order_value"].sum()),
"date_range": [
str(self.orders["order_date"].min().date()),
str(self.orders["order_date"].max().date()),
],
}

View File

@@ -0,0 +1,82 @@
"""
Feature 9 / Feature 5: Popularity Prediction (Regression).
Blends simulated engagement signals (views, wishlist adds - see
`app/intelligence/engagement_simulation.py`) with real simulated
purchase behaviour (orders, conversion rate) and a simulated rating,
into a single 0-100 popularity score. Bootstrapped the same way as the
discount model (see `synthetic_labels.py`): a documented weighted
formula generates training labels, a RandomForestRegressor learns the
general relationship, and only the trained model is used for serving -
which matters once real telemetry replaces the simulated
views/wishlist/rating inputs (the model doesn't need to change, only
its training data source does).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from app.intelligence import features as F
from app.intelligence.model_utils import ModelBundle, load_bundle, save_bundle
from app.intelligence.synthetic_labels import synthetic_popularity_score
MODEL_NAME = "popularity_model"
FEATURE_COLUMNS = ["views_norm", "wishlist_norm", "orders_norm", "rating_norm", "conversion_norm"]
def build_training_frame(product_engagement: pd.DataFrame) -> pd.DataFrame:
"""`product_engagement` columns: views, wishlist_count, orders_count,
avg_rating (1-5), conversion_rate (0-1)."""
df = pd.DataFrame({
"views_norm": F.normalize_0_100(product_engagement["views"]),
"wishlist_norm": F.normalize_0_100(product_engagement["wishlist_count"]),
"orders_norm": F.normalize_0_100(product_engagement["orders_count"]),
"rating_norm": F.normalize_0_100(product_engagement["avg_rating"]),
"conversion_norm": F.normalize_0_100(product_engagement["conversion_rate"]),
})
rng = np.random.default_rng(23)
df["popularity_score"] = synthetic_popularity_score(df, rng)
return df
def train(training_frame: pd.DataFrame) -> ModelBundle:
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error
X = training_frame[FEATURE_COLUMNS]
y = training_frame["popularity_score"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model = RandomForestRegressor(n_estimators=150, max_depth=6, random_state=42, n_jobs=-1)
model.fit(X_train, y_train)
mae = float(mean_absolute_error(y_test, model.predict(X_test)))
bundle = ModelBundle(
estimator=model, feature_columns=FEATURE_COLUMNS, model_name=MODEL_NAME,
n_samples=len(training_frame), extra={"val_mae_points": round(mae, 3)},
)
save_bundle(bundle)
return bundle
class PopularityScorer:
def __init__(self) -> None:
self._bundle: ModelBundle | None = None
def _ensure_loaded(self) -> bool:
if self._bundle is None:
self._bundle = load_bundle(MODEL_NAME)
return self._bundle is not None
def score(self, feature_df: pd.DataFrame) -> pd.Series:
if not self._ensure_loaded() or feature_df.empty:
return pd.Series([0.0] * len(feature_df), index=feature_df.index)
X = feature_df[self._bundle.feature_columns]
preds = np.clip(self._bundle.estimator.predict(X), 0, 100)
return pd.Series(preds, index=feature_df.index)
popularity_scorer = PopularityScorer()

View File

@@ -0,0 +1,99 @@
"""
Feature 9: Customer Purchase Prediction (Classification).
Binary classifier: will this customer place another order in the next
14-day window, given their RFM (Recency/Frequency/Monetary) history up
to a cut-off date? Unlike the discount/trending/popularity models, this
one needs NO synthetic label bootstrap - the label is real: we
time-split the simulated order history at a cut-off date, compute RFM
features from everything before it, and label = 1 if that customer has
>=1 order in the 14 days after it, else 0. This is standard churn/
purchase-propensity modelling methodology applied to (simulated) real
transactions.
LogisticRegression: a classification task with well-behaved, roughly
linearly-separable RFM features doesn't need a heavier model, and it
gives directly interpretable coefficients (useful for a "why" behind a
propensity score in the analytics UI).
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import List
import numpy as np
import pandas as pd
from app.intelligence import features as F
from app.intelligence.model_utils import ModelBundle, load_bundle, save_bundle
MODEL_NAME = "purchase_propensity_model"
FEATURE_COLUMNS = ["recency_days", "frequency", "monetary", "avg_order_value"]
PREDICTION_WINDOW_DAYS = 14
def build_training_frame(orders: pd.DataFrame, cutoff_dates: List[date]) -> pd.DataFrame:
"""`orders` columns: customer_id, order_date, order_value."""
rows = []
for cutoff in cutoff_dates:
cutoff_ts = pd.Timestamp(cutoff)
window_end = cutoff_ts + pd.Timedelta(days=PREDICTION_WINDOW_DAYS)
customers = orders.loc[orders["order_date"] < cutoff_ts, "customer_id"].unique()
future_buyers = set(
orders.loc[(orders["order_date"] >= cutoff_ts) & (orders["order_date"] < window_end), "customer_id"]
)
for cust in customers:
rfm = F.rfm_features(orders, cust, cutoff.__class__(cutoff.year, cutoff.month, cutoff.day))
rows.append({**rfm, "will_purchase": int(cust in future_buyers)})
return pd.DataFrame(rows)
def train(training_frame: pd.DataFrame) -> ModelBundle:
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
X = training_frame[FEATURE_COLUMNS]
y = training_frame["will_purchase"]
pipeline = Pipeline([("scale", StandardScaler()), ("clf", LogisticRegression(max_iter=500, class_weight="balanced"))])
auc = None
if y.nunique() > 1 and len(training_frame) >= 20:
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
pipeline.fit(X_train, y_train)
try:
auc = float(roc_auc_score(y_test, pipeline.predict_proba(X_test)[:, 1]))
except ValueError:
auc = None
else:
pipeline.fit(X, y)
bundle = ModelBundle(
estimator=pipeline, feature_columns=FEATURE_COLUMNS, model_name=MODEL_NAME,
n_samples=len(training_frame), extra={"val_auc": round(auc, 3) if auc is not None else None},
)
save_bundle(bundle)
return bundle
class PurchasePropensityPredictor:
def __init__(self) -> None:
self._bundle: ModelBundle | None = None
def _ensure_loaded(self) -> bool:
if self._bundle is None:
self._bundle = load_bundle(MODEL_NAME)
return self._bundle is not None
def predict_proba(self, feature_df: pd.DataFrame) -> pd.Series:
if not self._ensure_loaded() or feature_df.empty:
return pd.Series([0.5] * len(feature_df), index=feature_df.index)
X = feature_df[self._bundle.feature_columns]
proba = self._bundle.estimator.predict_proba(X)[:, 1]
return pd.Series(proba, index=feature_df.index)
purchase_propensity_predictor = PurchasePropensityPredictor()

View File

@@ -0,0 +1,151 @@
"""
Feature 7: ML-Based Product Recommendation Engine.
Three signals, blended (hybrid recommendation):
1. Embedding similarity (content-based, semantic). Reuses the SAME
sentence-transformers/all-MiniLM-L6-v2 embeddings the RAG pipeline
already computes and stores in pgvector for every product
(`app/services/embeddings_service.py`) - no new embedding model, no
extra inference cost. This is why "Tata Tea Gold" naturally recommends
"Brooke Bond Red Label" / "Taj Mahal Tea": their generated
descriptions land close together in embedding space regardless of
brand table.
2. TF-IDF similarity (content-based, lexical). A lightweight
scikit-learn TfidfVectorizer over title+category+brand text, added
because embedding similarity alone can miss exact-category/near-
duplicate matches when descriptions are stylistically different but
the products are practically identical substitutes (e.g. "Noodles"
across brands) - TF-IDF picks up shared category/brand vocabulary
that a semantic embedding sometimes smooths over.
3. Collaborative filtering (behavioural). Item-item cosine similarity
over a customer x product co-purchase matrix built from the
simulated order history (`order_items`) - "customers who bought X
also bought Y". Pure scipy/pandas, no extra ML library needed.
The final score is a weighted blend, and every recommendation returned
carries its own `similarity_score` (0-1) as the spec requires.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence
import numpy as np
import pandas as pd
from scipy import sparse
DEFAULT_WEIGHTS = {"embedding": 0.45, "tfidf": 0.20, "collaborative": 0.20, "popularity": 0.15}
def cosine_sim_matrix(vectors: np.ndarray) -> np.ndarray:
"""Row-normalized cosine similarity matrix for a (n, dim) array."""
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
norms[norms == 0] = 1e-9
normalized = vectors / norms
return normalized @ normalized.T
def embedding_similarity_to_source(source_vec: np.ndarray, candidate_vecs: np.ndarray) -> np.ndarray:
"""Cosine similarity of every row in candidate_vecs to a single
source_vec, mapped from pgvector's cosine *distance* convention
(0=identical, 2=opposite) is NOT used here - this takes raw
embedding vectors and computes similarity directly (1=identical,
-1=opposite), so callers passing pgvector distances must convert
first (`1 - distance` for pgvector's cosine distance)."""
source_norm = source_vec / max(np.linalg.norm(source_vec), 1e-9)
cand_norms = np.linalg.norm(candidate_vecs, axis=1)
cand_norms[cand_norms == 0] = 1e-9
return (candidate_vecs @ source_norm) / cand_norms
def tfidf_similarity(corpus: Sequence[str], source_index: int) -> np.ndarray:
"""TF-IDF cosine similarity of every document in `corpus` to
`corpus[source_index]`. `corpus` should be short text like
"<title> <category> <brand>" for each candidate product, with the
source product included at `source_index`."""
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
if len(corpus) < 2:
return np.zeros(len(corpus))
vectorizer = TfidfVectorizer(stop_words="english", max_features=2000)
matrix = vectorizer.fit_transform(corpus)
sims = cosine_similarity(matrix[source_index], matrix).ravel()
return sims
def build_copurchase_matrix(order_items: pd.DataFrame) -> tuple[sparse.csr_matrix, List[str]]:
"""Builds a (n_products x n_products) item-item co-occurrence-based
cosine similarity matrix from order history. Products are keyed by
'brand||image_id'. Returns (similarity_matrix, product_key_index).
"""
if order_items.empty:
return sparse.csr_matrix((0, 0)), []
items = order_items.copy()
items["product_key"] = items["brand"] + "||" + items["image_id"]
product_keys = sorted(items["product_key"].unique())
key_to_idx = {k: i for i, k in enumerate(product_keys)}
order_ids = sorted(items["order_id"].unique())
order_to_idx = {o: i for i, o in enumerate(order_ids)}
rows = items["product_key"].map(key_to_idx).to_numpy()
cols = items["order_id"].map(order_to_idx).to_numpy()
data = np.ones(len(items))
basket_matrix = sparse.csr_matrix((data, (rows, cols)), shape=(len(product_keys), len(order_ids)))
# Item-item cosine similarity via normalized dot product of the
# (product x order) incidence matrix - standard, lightweight
# collaborative-filtering approach (no external CF library needed).
norms = np.sqrt(basket_matrix.multiply(basket_matrix).sum(axis=1)).A.ravel()
norms[norms == 0] = 1e-9
inv_norm = sparse.diags(1.0 / norms)
normalized = inv_norm @ basket_matrix
sim = normalized @ normalized.T
return sparse.csr_matrix(sim), product_keys
@dataclass
class RecommendationCandidate:
brand: str
image_id: str
embedding_similarity: float = 0.0
tfidf_similarity: float = 0.0
collaborative_similarity: float = 0.0
popularity_norm: float = 0.0 # 0-1
def hybrid_score(self, weights: Optional[Dict[str, float]] = None) -> float:
w = weights or DEFAULT_WEIGHTS
score = (
w["embedding"] * self.embedding_similarity
+ w["tfidf"] * self.tfidf_similarity
+ w["collaborative"] * self.collaborative_similarity
+ w["popularity"] * self.popularity_norm
)
return float(np.clip(score, 0.0, 1.0))
def rank_candidates(
candidates: List[RecommendationCandidate],
top_k: int = 5,
weights: Optional[Dict[str, float]] = None,
) -> List[Dict]:
scored = [(c, c.hybrid_score(weights)) for c in candidates]
scored.sort(key=lambda t: t[1], reverse=True)
return [
{
"brand": c.brand,
"image_id": c.image_id,
"similarity_score": round(score, 4),
"signals": {
"embedding_similarity": round(c.embedding_similarity, 4),
"tfidf_similarity": round(c.tfidf_similarity, 4),
"collaborative_similarity": round(c.collaborative_similarity, 4),
"popularity_norm": round(c.popularity_norm, 4),
},
}
for c, score in scored[:top_k]
]

View File

@@ -0,0 +1,126 @@
"""
Feature 9: Store Performance Prediction (Regression).
Predicts a store's expected revenue for the NEXT 7-day period from its
own trailing performance features (rolling revenue, order volume,
average basket value, discount depth, footfall proxy, tier, weekday
mix). Trained on real (not synthetic) simulated daily store aggregates
- like `forecasting.py`, this is genuine time-series-derived supervised
learning: the label is the store's actual future revenue in the
simulation, not a bootstrapped formula.
RandomForestRegressor: robust to the small number of stores (5) and
the resulting modest sample size once rolled up daily, without needing
heavy tuning.
"""
from __future__ import annotations
from typing import List
import numpy as np
import pandas as pd
from app.intelligence import features as F
from app.intelligence.model_utils import ModelBundle, load_bundle, save_bundle
MODEL_NAME = "store_performance_model"
FEATURE_COLUMNS = [
"rolling_revenue_7", "rolling_revenue_14", "rolling_orders_7",
"avg_basket_value_7", "avg_discount_pct_7", "store_tier_encoded",
"month_sin", "month_cos",
]
def build_daily_store_aggregates(orders: pd.DataFrame, store_meta: pd.DataFrame) -> pd.DataFrame:
"""`orders` columns: store_id, order_date, order_value.
`store_meta` columns: store_id, tier.
Returns one row per (store_id, date) with revenue/order_count."""
if orders.empty:
return pd.DataFrame(columns=["store_id", "date", "revenue", "order_count"])
daily = orders.groupby(["store_id", orders["order_date"].dt.date]).agg(
revenue=("order_value", "sum"), order_count=("order_id", "count"),
).reset_index().rename(columns={"order_date": "date"})
daily["date"] = pd.to_datetime(daily["date"])
return daily.merge(store_meta, on="store_id", how="left")
def build_training_frame(daily_store_agg: pd.DataFrame, discount_avg_by_store_date: pd.DataFrame) -> pd.DataFrame:
"""`discount_avg_by_store_date` columns: store_id, date, avg_discount_pct."""
rows = []
merged = daily_store_agg.merge(discount_avg_by_store_date, on=["store_id", "date"], how="left")
merged["avg_discount_pct"] = merged["avg_discount_pct"].fillna(0.0)
for store_id, g in merged.sort_values("date").groupby("store_id"):
g = g.set_index("date")
revenue = g["revenue"].asfreq("D", fill_value=0)
orders_ct = g["order_count"].asfreq("D", fill_value=0)
discount = g["avg_discount_pct"].asfreq("D", fill_value=0)
tier_enc = F.encode_store_tier(g["tier"].iloc[0] if len(g) else "standard")
for i in range(21, len(revenue) - 7):
as_of = revenue.index[i]
future_revenue = revenue.iloc[i:i + 7].sum()
hist_rev = revenue.iloc[:i]
hist_orders = orders_ct.iloc[:i]
hist_disc = discount.iloc[:i]
basket = (hist_rev.tail(7).sum() / max(hist_orders.tail(7).sum(), 1))
rows.append({
"rolling_revenue_7": hist_rev.tail(7).mean(),
"rolling_revenue_14": hist_rev.tail(14).mean(),
"rolling_orders_7": hist_orders.tail(7).mean(),
"avg_basket_value_7": basket,
"avg_discount_pct_7": hist_disc.tail(7).mean(),
"store_tier_encoded": tier_enc,
**F.cyclical_month_features(as_of.date()),
"target_next7_revenue": future_revenue,
})
return pd.DataFrame(rows)
def train(training_frame: pd.DataFrame) -> ModelBundle:
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error
X = training_frame[FEATURE_COLUMNS]
y = training_frame["target_next7_revenue"]
if len(training_frame) < 10:
# Too few samples (very short simulated history) for a train/test
# split to be meaningful - fit on everything and report NaN MAE
# rather than crashing.
model = RandomForestRegressor(n_estimators=100, max_depth=5, random_state=42, n_jobs=-1)
model.fit(X, y)
mae = float("nan")
else:
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model = RandomForestRegressor(n_estimators=200, max_depth=7, random_state=42, n_jobs=-1)
model.fit(X_train, y_train)
mae = float(mean_absolute_error(y_test, model.predict(X_test)))
bundle = ModelBundle(
estimator=model, feature_columns=FEATURE_COLUMNS, model_name=MODEL_NAME,
n_samples=len(training_frame), extra={"val_mae_revenue": round(mae, 2) if mae == mae else None},
)
save_bundle(bundle)
return bundle
class StorePerformancePredictor:
def __init__(self) -> None:
self._bundle: ModelBundle | None = None
def _ensure_loaded(self) -> bool:
if self._bundle is None:
self._bundle = load_bundle(MODEL_NAME)
return self._bundle is not None
def predict(self, feature_df: pd.DataFrame) -> pd.Series:
if not self._ensure_loaded() or feature_df.empty:
return pd.Series([0.0] * len(feature_df), index=feature_df.index)
X = feature_df[self._bundle.feature_columns]
preds = np.clip(self._bundle.estimator.predict(X), 0, None)
return pd.Series(preds, index=feature_df.index)
store_performance_predictor = StorePerformancePredictor()

View File

@@ -0,0 +1,217 @@
"""
Multi-store product distribution, pricing, and inventory generation.
Implements Feature 1 (Multi-Store Product Distribution + Store Pricing)
and Feature 2 (Product Stock Management) as pure functions over the
existing catalog data (read from pgvector via `services/store_db.py`,
never mutated here) - this module never touches the database itself, so
it's fully unit-testable and reusable from both the one-off seed script
and, if wanted later, an admin "reshuffle stores" endpoint.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional
import numpy as np
from app.intelligence.order_simulation import StoreProfile, deterministic_unit, latent_popularity
from app.services.price_estimator import classify_category, estimate_price, parse_price_string
# ---------------------------------------------------------------------------
# The 5 required stores. footfall_index is the baseline avg orders/day used
# by the order simulator (before weekday/seasonal multipliers) - premium
# stores in this simulated chain are smaller/boutique (lower footfall, higher
# price realization); standard/budget stores are higher-volume.
# ---------------------------------------------------------------------------
DEFAULT_STORES: List[StoreProfile] = [
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 - 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
# product should not necessarily exist in every store"). Premium/boutique
# stores curate a smaller assortment; high-volume stores stock more.
STORE_ASSORTMENT_RATIO = {
"STORE-A": 0.55,
"STORE-B": 0.75,
"STORE-C": 0.85,
"STORE-D": 0.70,
"STORE-E": 0.80,
}
# Approximate typical Indian FMCG gross-margin bands per pricing category
# (from price_estimator.CATEGORY_BANDS keys). These are deliberately
# approximate, documented assumptions, not sourced from any single
# retailer's real books - used only to keep simulated cost prices
# realistic relative to MRP.
CATEGORY_COST_RATIO = {
"biscuits_cookies": 0.80, "crackers": 0.80, "rusk": 0.82, "bakery_bread": 0.78,
"cakes_muffins": 0.75, "chocolates": 0.78, "snacks_namkeen": 0.78, "dairy": 0.85,
"beverages_juice": 0.80, "beverages_tea_coffee": 0.76, "breakfast_cereal": 0.78,
"oral_care": 0.72, "hair_care": 0.70, "skin_bath": 0.72, "household_clean": 0.75,
"baby_care": 0.74, "general": 0.78,
}
# Store-tier pricing stance: premium stores price closer to MRP (less
# aggressive discounting on the shelf price itself - actual promotional
# discounts are handled separately by the ML discount model); budget/
# high-footfall stores price more competitively below MRP.
STORE_TIER_PRICE_FACTOR = {"premium": (0.97, 1.0), "standard": (0.93, 0.99), "budget": (0.88, 0.97)}
@dataclass
class ProductRef:
brand: str
image_id: str
title: str
category: Optional[str]
price_range: Optional[str]
@dataclass
class ProvisionedProduct:
store_id: str
brand: str
image_id: str
category: str
mrp: float
cost_price: float
selling_price: float
available_stock: int
reserved_stock: int
reorder_level: int
safety_stock: int
@property
def profit_margin(self) -> float:
return round(self.selling_price - self.cost_price, 2)
@property
def gross_profit_pct(self) -> float:
if self.selling_price <= 0:
return 0.0
return round((self.profit_margin / self.selling_price) * 100, 2)
@property
def markup_pct(self) -> float:
if self.cost_price <= 0:
return 0.0
return round((self.profit_margin / self.cost_price) * 100, 2)
@property
def stock_status(self) -> str:
if self.available_stock <= 0:
return "Out of Stock"
if self.available_stock <= self.safety_stock:
return "Low Stock"
if self.available_stock > self.reorder_level * 6:
return "Overstocked"
return "In Stock"
def resolve_mrp(product: ProductRef) -> float:
"""MRP is fixed per product (Feature 1 pricing rule: 'Keep MRP
fixed') - it never varies by store. Prefer the real price already
stored in the catalog (`price_range`, produced by the existing
price_estimator-backed ingestion pipeline); fall back to
`estimate_price` for a product with no usable price_range."""
parsed = parse_price_string(product.price_range or "")
if parsed and parsed > 0:
return float(parsed)
return float(estimate_price("100g", product.title, product.brand, product.category or ""))
def _stock_base_units(category_key: str, tier: str) -> int:
"""A category-appropriate baseline stock level before popularity and
store-tier scaling. Fast-moving low-unit-price categories (biscuits,
snacks) are stocked deeper than slow-moving/expensive categories."""
high_velocity = {"biscuits_cookies", "snacks_namkeen", "beverages_tea_coffee", "dairy", "crackers"}
base = 260 if category_key in high_velocity else 140
tier_mult = {"premium": 0.75, "standard": 1.0, "budget": 1.15}.get(tier, 1.0)
return int(base * tier_mult)
def provision_stores(
products: List[ProductRef],
stores: Optional[List[StoreProfile]] = None,
seed: int = 42,
) -> Dict[str, List[ProvisionedProduct]]:
"""Assign a random-but-reproducible subset of `products` to each
store, and generate independent per-store pricing + inventory for
every assigned product.
Returns {store_id: [ProvisionedProduct, ...]}.
"""
stores = stores or DEFAULT_STORES
rng = np.random.default_rng(seed)
result: Dict[str, List[ProvisionedProduct]] = {s.store_id: [] for s in stores}
for store in stores:
ratio = STORE_ASSORTMENT_RATIO.get(store.store_id, 0.70)
lo_factor, hi_factor = STORE_TIER_PRICE_FACTOR.get(store.tier, (0.92, 0.99))
for product in products:
# Deterministic-but-store-specific inclusion draw so re-running
# the seed script reproduces the same assortment.
pop = latent_popularity(product.brand, product.image_id)
inclusion_prob = min(0.98, ratio * (0.6 + 0.8 * pop)) # popular items more likely to be stocked everywhere
draw = deterministic_unit(f"assort|{store.store_id}|{product.brand}|{product.image_id}")
if draw > inclusion_prob:
continue
category_key = classify_category(product.title, product.category or "")
mrp = resolve_mrp(product)
cost_ratio = CATEGORY_COST_RATIO.get(category_key, 0.78)
cost_price = round(mrp * cost_ratio, 2)
# Per-store, per-product price jitter within the tier's factor
# band, seeded so it's stable across re-runs but differs across
# stores/products (Feature 1: "Price should vary between
# stores... Store prices independently").
jitter = deterministic_unit(f"price|{store.store_id}|{product.brand}|{product.image_id}")
factor = lo_factor + jitter * (hi_factor - lo_factor)
selling_price = round(mrp * factor, 2)
# Guardrail: never below a minimal viable margin, never above MRP.
min_viable = round(cost_price * 1.03, 2)
selling_price = max(min_viable, min(selling_price, mrp))
base_units = _stock_base_units(category_key, store.tier)
stock_jitter = rng.lognormal(mean=0.0, sigma=0.32)
available_stock = max(0, int(base_units * (0.55 + 0.7 * pop) * stock_jitter))
# ~4% of stores/products simulate a stockout so the "Out of
# Stock" status and its downstream discount/analytics effects
# actually show up in the demo data.
if deterministic_unit(f"oos|{store.store_id}|{product.brand}|{product.image_id}") < 0.04:
available_stock = 0
reorder_level = max(5, int(base_units * 0.30))
safety_stock = max(2, int(reorder_level * 0.5))
# ~10% of stocked (non-zero) rows simulate a "running low, not
# yet reordered" state so the Low Stock status and its
# downstream reorder-alert / higher-discount effects actually
# show up in the demo data instead of only In Stock/Overstocked.
if available_stock > 0 and deterministic_unit(
f"lowstock|{store.store_id}|{product.brand}|{product.image_id}"
) < 0.10:
available_stock = max(1, int(safety_stock * rng.uniform(0.3, 0.95)))
reserved_stock = int(available_stock * rng.uniform(0.0, 0.08))
result[store.store_id].append(ProvisionedProduct(
store_id=store.store_id,
brand=product.brand,
image_id=product.image_id,
category=product.category or "Uncategorized",
mrp=mrp,
cost_price=cost_price,
selling_price=selling_price,
available_stock=available_stock,
reserved_stock=reserved_stock,
reorder_level=reorder_level,
safety_stock=safety_stock,
))
return result

View File

@@ -0,0 +1,129 @@
"""
Synthetic training-label generators.
WHY THIS FILE EXISTS
---------------------
Two of the requested models - discount prediction and popularity scoring -
ask for a *regression* that predicts a number no historical dataset
actually contains yet ("what discount % SHOULD this product have had?",
"what SHOULD this product's popularity score be?"). There is no ground
truth for that in a brand-new system with no real transaction history.
The standard way to bootstrap a supervised model in this situation is:
1. Write down a transparent, multi-factor formula that encodes business
intuition (the one below mirrors the stock-tier example in the spec,
extended with demand/seasonality/expiry/category factors).
2. Add realistic noise to it, so the model doesn't just re-derive the
exact formula (which would make the "model" pointless) but instead
learns the *general relationship* between features and outcome,
including interactions the flat formula doesn't capture.
3. Train a regressor on the (features -> noisy-formula-label) pairs.
4. At INFERENCE time, only the trained model is used - never this
formula. New stock/demand/seasonal combinations the formula was
never explicitly tuned for still get a sensible prediction because
the model has generalized, and because the model also blends in the
real simulated sales-velocity/popularity features (which the flat
formula doesn't use fully), its predictions diverge from the raw
formula in exactly the way a supervised model is supposed to.
If/when this system accumulates real discount history (i.e. actual
markdowns and the resulting sales lift), `discount_model.py` should be
retrained on that real data instead and this module becomes unnecessary
for discounts. `popularity_model.py` can similarly be swapped to train on
real click/purchase telemetry once it exists.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
def synthetic_discount_pct(df: pd.DataFrame, rng: np.random.Generator) -> pd.Series:
"""Formula-plus-noise target for discount %, in [0, 35].
Expected columns in `df`: stock_ratio, days_of_cover, units_per_day_30d,
demand_score (0-100), popularity_score (0-100), days_to_expiry,
is_festive_season (0/1), category_freq (0-1).
Directional logic (all learned relationships, not applied at
inference - see module docstring):
- More stock relative to reorder point -> higher discount (clear
the shelf).
- More days-of-cover than the category needs -> higher discount
(overstocked).
- Higher recent velocity / demand / popularity -> LOWER discount
(it's already selling, no need to discount it).
- Close to expiry -> higher discount (perishables urgency).
- Festive season -> a modest promotional discount bump.
- Niche/low-frequency categories get slightly higher clearance
discounts than high-turnover staples.
"""
stock_component = np.clip(df["stock_ratio"] * 9.0, 0, 22)
overstock_component = np.clip((df["days_of_cover"] - 20) * 0.35, 0, 10)
demand_relief = np.clip((df["demand_score"] + df["popularity_score"]) / 2.0 * 0.12, 0, 12)
expiry_component = np.where(
df["days_to_expiry"] <= 3, 14,
np.where(df["days_to_expiry"] <= 7, 8, np.where(df["days_to_expiry"] <= 14, 3, 0)),
)
festive_component = df["is_festive_season"] * 3.0
niche_component = (1.0 - df["category_freq"].clip(0, 1)) * 2.0
raw = (
stock_component
+ overstock_component
+ expiry_component
+ festive_component
+ niche_component
- demand_relief
)
noise = rng.normal(loc=0.0, scale=1.8, size=len(df))
return pd.Series(np.clip(raw + noise, 0, 35), index=df.index)
def synthetic_trend_score(df: pd.DataFrame, rng: np.random.Generator) -> pd.Series:
"""Formula-plus-noise target for a 0-100 'trending' score.
Expected columns: growth_pct (current vs previous window, can be
negative), revenue_growth_pct, order_count_current, recency_days
(days since the most recent order - lower is more 'alive right
now'), unique_customers_current.
A product is "trending" when it is both growing quickly AND has
enough absolute recent activity to be a meaningful signal (a jump
from 1 order to 2 orders is a 100% growth rate but not actually
trending) - the order_count/unique_customer terms exist so the
model learns to discount growth-rate spikes on near-zero volume,
which a naive "sort by growth %" rule (the literal hardcoded
approach the spec asks us to avoid) would get wrong.
"""
growth_component = np.clip(df["growth_pct"], -1, 5) * 12.0
revenue_component = np.clip(df["revenue_growth_pct"], -1, 5) * 8.0
volume_component = np.log1p(df["order_count_current"].clip(lower=0)) * 6.0
reach_component = np.log1p(df["unique_customers_current"].clip(lower=0)) * 5.0
recency_component = np.clip(14 - df["recency_days"], 0, 14) * 1.5
raw = growth_component + revenue_component + volume_component + reach_component + recency_component
noise = rng.normal(loc=0.0, scale=3.5, size=len(df))
scaled = np.clip(raw + noise, 0, None)
# Squash into 0-100 with a soft cap so a handful of extreme outliers
# don't compress everything else near zero.
return pd.Series(100 * (1 - np.exp(-scaled / 40.0)), index=df.index)
def synthetic_popularity_score(df: pd.DataFrame, rng: np.random.Generator) -> pd.Series:
"""Formula-plus-noise target for popularity, 0-100.
Expected columns: views_norm, wishlist_norm, orders_norm,
rating_norm, conversion_norm (all already 0-100 normalized).
Weighted blend chosen to reflect that actual purchases matter more
than passive views, mirroring typical e-commerce popularity scoring.
"""
raw = (
0.15 * df["views_norm"]
+ 0.15 * df["wishlist_norm"]
+ 0.40 * df["orders_norm"]
+ 0.15 * df["rating_norm"]
+ 0.15 * df["conversion_norm"]
)
noise = rng.normal(loc=0.0, scale=4.0, size=len(df))
return pd.Series(np.clip(raw + noise, 0, 100), index=df.index)

View File

@@ -0,0 +1,167 @@
"""
Feature 6: ML-Based Trending Product Detection.
Approach: rolling-window time-series feature engineering (current vs.
previous period unit/revenue growth, order frequency, customer reach,
recency) feeding a GradientBoostingRegressor that predicts a 0-100
trend score. This is one of the spec's own listed options ("Gradient
Boosting", "Random Forest") - chosen over Prophet/LSTM for the
hardware-conscious reasons in `app/intelligence/__init__.py`. The time
window aggregation (daily/weekly/monthly rollups, WoW/MoM growth) *is*
the time-series component; Prophet/LSTM would model the same rollups
with heavier machinery for a marginal accuracy gain that isn't worth
the RAM/CPU budget here.
Nothing is ever hardcoded as "the trending list" - every ranking below
is `predicted_score.sort_values(ascending=False)` on live order data.
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import Dict, List, Literal, Optional
import numpy as np
import pandas as pd
from app.intelligence.model_utils import ModelBundle, load_bundle, save_bundle
from app.intelligence.synthetic_labels import synthetic_trend_score
MODEL_NAME = "trending_model"
FEATURE_COLUMNS = [
"growth_pct", "revenue_growth_pct", "order_count_current",
"unique_customers_current", "recency_days",
]
Window = Literal["today", "weekly", "monthly"]
WINDOW_DAYS: Dict[Window, int] = {"today": 1, "weekly": 7, "monthly": 30}
def _window_bounds(as_of: date, window: Window) -> tuple[pd.Timestamp, pd.Timestamp, pd.Timestamp]:
days = WINDOW_DAYS[window]
end = pd.Timestamp(as_of)
current_start = end - pd.Timedelta(days=days)
previous_start = current_start - pd.Timedelta(days=days)
return previous_start, current_start, end
def compute_trend_features(
order_items: pd.DataFrame,
orders: pd.DataFrame,
as_of: date,
window: Window,
group_cols: List[str],
) -> pd.DataFrame:
"""`group_cols` is either ['brand', 'image_id'] (overall/category
scope, pooled across stores) or ['store_id', 'brand', 'image_id']
(store-wise scope).
`order_items` must already carry `order_date` and `customer_id`
columns - this is the shape `store_db.get_order_items_df()` returns
(it joins those in from `orders` at the SQL layer so every caller
across this package can rely on the same enriched shape rather than
each re-joining separately). `orders` is accepted for API symmetry
with other builders in this module but isn't re-merged here.
Returns one row per group with FEATURE_COLUMNS populated from real
simulated order history - no synthetic data at this stage, only the
downstream label used for *training* is synthetic (see
synthetic_labels.py); features here are 100% derived from actual
simulated transactions.
"""
if order_items.empty or "order_date" not in order_items.columns:
return pd.DataFrame(columns=group_cols + FEATURE_COLUMNS)
merged = order_items
prev_start, cur_start, cur_end = _window_bounds(as_of, window)
current = merged[(merged["order_date"] >= cur_start) & (merged["order_date"] < cur_end)]
previous = merged[(merged["order_date"] >= prev_start) & (merged["order_date"] < cur_start)]
cur_agg = current.groupby(group_cols).agg(
units_current=("quantity", "sum"),
revenue_current=("line_total", "sum"),
order_count_current=("order_id", "nunique"),
unique_customers_current=("customer_id", "nunique"),
).reset_index()
prev_agg = previous.groupby(group_cols).agg(
units_previous=("quantity", "sum"),
revenue_previous=("line_total", "sum"),
).reset_index()
last_seen = merged.groupby(group_cols)["order_date"].max().reset_index().rename(columns={"order_date": "last_order_date"})
df = cur_agg.merge(prev_agg, on=group_cols, how="left").merge(last_seen, on=group_cols, how="left")
df[["units_previous", "revenue_previous"]] = df[["units_previous", "revenue_previous"]].fillna(0.0)
df["growth_pct"] = (df["units_current"] - df["units_previous"]) / df["units_previous"].replace(0, np.nan)
df["growth_pct"] = df["growth_pct"].fillna(df["units_current"].clip(upper=1.0)) # brand-new activity counts as modest growth, not undefined
df["revenue_growth_pct"] = (df["revenue_current"] - df["revenue_previous"]) / df["revenue_previous"].replace(0, np.nan)
df["revenue_growth_pct"] = df["revenue_growth_pct"].fillna(df["revenue_current"].clip(upper=1.0) / max(df["revenue_current"].max(), 1))
df["recency_days"] = (pd.Timestamp(as_of) - df["last_order_date"]).dt.days.clip(lower=0)
return df[group_cols + FEATURE_COLUMNS]
def train(training_frame: pd.DataFrame) -> ModelBundle:
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_absolute_error
X = training_frame[FEATURE_COLUMNS]
y = training_frame["trend_score"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model = GradientBoostingRegressor(n_estimators=150, max_depth=3, learning_rate=0.08, subsample=0.9, random_state=42)
model.fit(X_train, y_train)
mae = float(mean_absolute_error(y_test, model.predict(X_test)))
bundle = ModelBundle(
estimator=model, feature_columns=FEATURE_COLUMNS, model_name=MODEL_NAME,
n_samples=len(training_frame),
extra={"val_mae_points": round(mae, 3)},
)
save_bundle(bundle)
return bundle
def build_training_frame(order_items: pd.DataFrame, orders: pd.DataFrame, as_of_dates: List[date]) -> pd.DataFrame:
"""Builds training examples across several historical `as_of` cut
points and all three windows, so the model sees a range of
growth/recency patterns rather than a single snapshot."""
frames = []
for as_of in as_of_dates:
for window in ("today", "weekly", "monthly"):
f = compute_trend_features(order_items, orders, as_of, window, ["brand", "image_id"])
if not f.empty:
f["window"] = window
frames.append(f)
if not frames:
return pd.DataFrame(columns=FEATURE_COLUMNS + ["trend_score"])
df = pd.concat(frames, ignore_index=True)
rng = np.random.default_rng(11)
df["trend_score"] = synthetic_trend_score(df, rng)
return df
class TrendingScorer:
def __init__(self) -> None:
self._bundle: ModelBundle | None = None
def _ensure_loaded(self) -> bool:
if self._bundle is None:
self._bundle = load_bundle(MODEL_NAME)
return self._bundle is not None
def score(self, feature_df: pd.DataFrame) -> pd.Series:
"""Returns a 0-100 predicted trend score aligned to feature_df's
index. Falls back to a neutral 0.0 (never a hardcoded ranking)
if no model has been trained yet."""
if not self._ensure_loaded() or feature_df.empty:
return pd.Series([0.0] * len(feature_df), index=feature_df.index)
X = feature_df[self._bundle.feature_columns]
preds = np.clip(self._bundle.estimator.predict(X), 0, 100)
return pd.Series(preds, index=feature_df.index)
trending_scorer = TrendingScorer()

107
app/main.py Normal file
View File

@@ -0,0 +1,107 @@
"""
FastAPI application entry point.
Run with:
uvicorn app.main:app --reload --port 8000
(see backend/README.md / the project documentation for full setup steps)
"""
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, system
from app.api.routers import stores, discounts, analytics as store_analytics, trending, recommendations, store_admin
from app.api.routers import nutrition, nutrition_admin, upload
from app.api.routers import auth, user_products, admin_train
from app.services.store_db import ensure_store_intelligence_schema
from app.services.nutrition_db import ensure_nutrition_schema
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
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. "
"Includes automated multi-store intelligence, ML discount engines, and nutrition intelligence."
),
version="3.2.0",
)
@app.on_event("startup")
def _on_startup() -> None:
# 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(
CORSMiddleware,
allow_origins=API_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
app.include_router(user_products.router, prefix="/api")
app.include_router(admin_train.router, prefix="/api")
app.include_router(system.router, prefix="/api")
app.include_router(brands.router, prefix="/api")
app.include_router(search.router, prefix="/api")
app.include_router(chat.router, prefix="/api")
app.include_router(catalog.router, prefix="/api")
app.include_router(stores.router, prefix="/api")
app.include_router(discounts.router, prefix="/api")
app.include_router(store_analytics.router, prefix="/api")
app.include_router(trending.router, prefix="/api")
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("/{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",
}

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
from typing import Dict, List, Optional
import pandas as pd
from app.intelligence import analytics as A
from app.intelligence.popularity_model import popularity_scorer
from app.intelligence import engagement_simulation
from app.services import store_db
def store_dashboard(store_id: str) -> Dict:
orders = store_db.get_orders_df(store_id=store_id)
order_items = store_db.get_order_items_df(store_id=store_id)
store_products = store_db.get_all_store_products_df()
store_products_here = store_products[store_products["store_id"] == store_id]
sales = A.sales_analytics(orders)
profit = A.profit_analytics(order_items, store_products_here[["store_id", "brand", "image_id", "cost_price"]])
inventory = A.inventory_analytics(store_products_here)
top_sellers = A.top_products(order_items, limit=10, by="revenue")
lowest_sellers = A.top_products(order_items, limit=10, by="units", ascending=True)
return {
"store_id": store_id, "sales": sales, "profit": profit, "inventory": inventory,
"top_selling_products": top_sellers, "lowest_selling_products": lowest_sellers,
}
def chain_comparison() -> Dict:
orders = store_db.get_orders_df()
order_items = store_db.get_order_items_df()
store_products = store_db.get_all_store_products_df()
stores_df = pd.DataFrame(store_db.list_stores())
if stores_df.empty:
return {"stores": [], "best_performing": None, "lowest_performing": None, "highest_revenue": None, "highest_profit": None}
stores_df = stores_df[["store_id", "store_name", "tier", "footfall_index"]]
return A.store_comparison(orders, order_items, store_products[["store_id", "brand", "image_id", "cost_price"]], stores_df)
def product_dashboard(brand: str, image_id: str) -> Dict:
order_items = store_db.get_order_items_df()
store_products = store_db.get_all_store_products_df()
products = store_products[["brand", "image_id"]].drop_duplicates()
orders_count = order_items.groupby(order_items["brand"] + "||" + order_items["image_id"])["quantity"].sum()
eng_df = engagement_simulation.simulate_engagement(products[(products["brand"] == brand) & (products["image_id"] == image_id)], orders_count)
engagement_row = eng_df.iloc[0].to_dict() if not eng_df.empty else None
popularity = None
if engagement_row:
from app.intelligence import features as F
feat_row = pd.DataFrame([{
"views_norm": min(engagement_row["views"] / 10, 100),
"wishlist_norm": min(engagement_row["wishlist_count"] / 2, 100),
"orders_norm": min(engagement_row["orders_count"] * 2, 100),
"rating_norm": engagement_row["avg_rating"] * 20,
"conversion_norm": engagement_row["conversion_rate"] * 100,
}])
popularity = float(popularity_scorer.score(feat_row).iloc[0])
return A.product_analytics(
brand, image_id, order_items,
store_products[["store_id", "brand", "image_id", "selling_price", "cost_price"]],
engagement_row=engagement_row, popularity_score=popularity,
)
def top_products(by: str = "revenue", limit: int = 10, ascending: bool = False) -> List[Dict]:
order_items = store_db.get_order_items_df()
return A.top_products(order_items, limit=limit, ascending=ascending, by=by)

View File

@@ -0,0 +1,293 @@
BRAND_ALIASES = {
# Cadbury family
"cadbury gems": "cadbury",
"cadbury chocolates": "cadbury",
"cadbury dairy milk": "cadbury",
"cadbury 5 star": "cadbury",
"cadbury bournvita": "cadbury",
"cadbury oreo": "cadbury",
"cadbury celebrations": "cadbury",
"cadbury perk": "cadbury",
"cadbury temptations": "cadbury",
"cadbury eclairs": "cadbury",
"cadbury silk": "cadbury",
# Brooke Bond family
"brooke bond red label": "brooke bond",
"brooke bond taj mahal": "brooke bond",
"brooke bond yellow label": "brooke bond",
"brooke bond select": "brooke bond",
"brooke bond strong": "brooke bond",
"brooke bond natural care": "brooke bond",
# Parle family
"parle biscuits": "parle",
"parle hide & seek": "parle",
"parle milano": "parle",
"parle parle-g": "parle",
"parle krackjack": "parle",
"parle monaco": "parle",
"parle 20-20": "parle",
"parle fab": "parle",
"parle nutri": "parle",
"parle magix": "parle",
# Britannia family
"britannia good day": "britannia",
"britannia tiger": "britannia",
"britannia nutrichoice": "britannia",
"britannia milk bikis": "britannia",
"britannia marie gold": "britannia",
"britannia bourbon": "britannia",
"britannia little hearts": "britannia",
"britannia treat": "britannia",
"britannia 50-50": "britannia",
"britannia jim jam": "britannia",
# Nestle family
"nestle maggi": "nestle",
"nestle kitkat": "nestle",
"nestle milkybar": "nestle",
"nestle nescafe": "nestle",
"nestle cheerios": "nestle",
"nestle lactogen": "nestle",
"nestle cerelac": "nestle",
"nestle everyday": "nestle",
"nestle munch": "nestle",
"nestle bar-one": "nestle",
"nestle polo": "nestle",
"nestle nestea": "nestle",
"nestle milkmaid": "nestle",
"nestle a+": "nestle",
"nestle slim": "nestle",
"nestle nan": "nestle",
"nestle resource": "nestle",
"nestle goody": "nestle",
"nestle milo": "nestle",
"nestle fresh & natural": "nestle",
"nestle mithai mate": "nestle",
"nestle masala ae magic": "nestle",
"nestle hot heads": "nestle",
"nestle butter": "nestle",
"nestle ghee": "nestle",
"nestle paneer": "nestle",
"nestle peptamen": "nestle",
# Hindustan Unilever (HUL) family
"hul lux": "hindustan unilever",
"hul lifebuoy": "hindustan unilever",
"hul dove": "hindustan unilever",
"hul sunsilk": "hindustan unilever",
"hul clinic plus": "hindustan unilever",
"hul ponds": "hindustan unilever",
"hul fair & lovely": "hindustan unilever",
"hul axe": "hindustan unilever",
"hul closeup": "hindustan unilever",
"hul pepsodent": "hindustan unilever",
"hul rin": "hindustan unilever",
"hul surf excel": "hindustan unilever",
"hul wheel": "hindustan unilever",
"hul vim": "hindustan unilever",
"hul knorr": "hindustan unilever",
"hul lipton": "hindustan unilever",
"hul bru": "hindustan unilever",
"hul kwality walls": "hindustan unilever",
"hul horlicks": "hindustan unilever",
"hul boost": "hindustan unilever",
"hul maltova": "hindustan unilever",
"hul laxmi": "hindustan unilever",
"hul tata tea": "hindustan unilever",
# P&G (Procter & Gamble) family
"pg pantene": "p&g",
"pg head & shoulders": "p&g",
"pg gillette": "p&g",
"pg vicks": "p&g",
"pg ariel": "p&g",
"pg tide": "p&g",
"pg pampers": "p&g",
"pg whisper": "p&g",
"pg oral-b": "p&g",
"pg old spice": "p&g",
"pg ivory": "p&g",
"pg pringles": "p&g",
"pg downy": "p&g",
"pg tide pods": "p&g",
"pg fairy": "p&g",
"pg always": "p&g",
"pg dawn": "p&g",
# ITC family
"itc sunfeast": "itc",
"itc bingo": "itc",
"itc yippee": "itc",
"itc aashirvaad": "itc",
"itc fiama": "itc",
"itc engage": "itc",
"itc savlon": "itc",
"itc nimyle": "itc",
"itc classmate": "itc",
"itc mangaldeep": "itc",
"itc candyman": "itc",
"itc sunfeast dark fantasy": "itc",
"itc sunfeast marie": "itc",
"itc sunfeast bounce": "itc",
"itc bingo tedhe medhe": "itc",
"itc farmlite": "itc",
# Marico family
"marico parachute": "marico",
"marico saffola": "marico",
"marico livon": "marico",
"marico set wet": "marico",
"marico hair & care": "marico",
"marico nihar": "marico",
"marico mediker": "marico",
"marico kaya": "marico",
"marico veggie cleanse": "marico",
# Dabur family
"dabur amla": "dabur",
"dabur vatika": "dabur",
"dabur red": "dabur",
"dabur chyawanprash": "dabur",
"dabur hajmola": "dabur",
"dabur real": "dabur",
"dabur pudin hara": "dabur",
"dabur honitus": "dabur",
"dabur odomos": "dabur",
"dabur gulabari": "dabur",
"dabur lal tail": "dabur",
"dabur janam ghutti": "dabur",
"dabur meswak": "dabur",
"dabur promite": "dabur",
# Amul family
"amul butter": "amul",
"amul milk": "amul",
"amul cheese": "amul",
"amul ice cream": "amul",
"amul ghee": "amul",
"amul mithai": "amul",
"amul paneer": "amul",
"amul cream": "amul",
"amul dahi": "amul",
"amul lassi": "amul",
"amul kool": "amul",
"amul pro": "amul",
"amul spray": "amul",
"amul shrikhand": "amul",
"amul basundi": "amul",
# Colgate-Palmolive family
"colgate dental cream": "colgate-palmolive",
"colgate maxfresh": "colgate-palmolive",
"colgate sensitive": "colgate-palmolive",
"colgate total": "colgate-palmolive",
"colgate 360": "colgate-palmolive",
"colgate active salt": "colgate-palmolive",
"palmolive shampoo": "colgate-palmolive",
"palmolive body wash": "colgate-palmolive",
"palmolive soap": "colgate-palmolive",
"colgate toothbrush": "colgate-palmolive",
# Johnson & Johnson family
"johnson & johnson": "johnson & johnson",
"jnj baby shampoo": "johnson & johnson",
"jnj baby oil": "johnson & johnson",
"jnj baby lotion": "johnson & johnson",
"jnj baby powder": "johnson & johnson",
"jnj stayfree": "johnson & johnson",
"jnj listerine": "johnson & johnson",
"jnj band-aid": "johnson & johnson",
"jnj neutrogena": "johnson & johnson",
"jnj clean & clear": "johnson & johnson",
"jnj savlon": "johnson & johnson",
"jnj benadryl": "johnson & johnson",
# Reckitt Benckiser family
"rb dettol": "reckitt benckiser",
"rb lizol": "reckitt benckiser",
"rb harpic": "reckitt benckiser",
"rb mortein": "reckitt benckiser",
"rb vanish": "reckitt benckiser",
"rb durex": "reckitt benckiser",
"rb strepsils": "reckitt benckiser",
"rb mucinex": "reckitt benckiser",
"rb finish": "reckitt benckiser",
"rb veet": "reckitt benckiser",
# Godrej Consumer family
"godrej no.1": "godrej",
"godrej cinthol": "godrej",
"godrej expert": "godrej",
"godrej hit": "godrej",
"godrej good knight": "godrej",
"godrej aer": "godrej",
"godrej protekt": "godrej",
"godrej magic": "godrej",
"godrej renew": "godrej",
# PepsiCo family
"pepsico lays": "pepsico",
"pepsico kurkure": "pepsico",
"pepsico doritos": "pepsico",
"pepsico cheetos": "pepsico",
"pepsico quaker": "pepsico",
"pepsico pepsi": "pepsico",
"pepsico 7up": "pepsico",
"pepsico mountain dew": "pepsico",
"pepsico mirinda": "pepsico",
"pepsico tropicana": "pepsico",
"pepsico gatorade": "pepsico",
"pepsico nimbooz": "pepsico",
"pepsico stings": "pepsico",
"pepsico lehar": "pepsico",
"pepsico slice": "pepsico",
# Coca-Cola family
"coca-cola classic": "coca-cola",
"coca-cola thums up": "coca-cola",
"coca-cola sprite": "coca-cola",
"coca-cola fanta": "coca-cola",
"coca-cola maaza": "coca-cola",
"coca-cola minute maid": "coca-cola",
"coca-cola diet coke": "coca-cola",
"coca-cola zero": "coca-cola",
"coca-cola kinley": "coca-cola",
"coca-cola smartwater": "coca-cola",
"coca-cola limca": "coca-cola",
# Sunfeast family
"sunfeast marie light": "sunfeast",
"sunfeast marie": "sunfeast",
"sunfeast dark fantasy": "sunfeast",
"sunfeast bounce": "sunfeast",
"sunfeast yippee": "sunfeast",
"sunfeast cookies": "sunfeast",
}
DEFAULT_ALIASES = BRAND_ALIASES
def resolve_parent_brand(brand: str) -> str:
"""Return the parent (canonical) brand for storage purposes.
Looks up the brand (case-insensitive) in BRAND_ALIASES. If found,
returns the parent brand name so that sub-brands share the same
database table, S3 folder, and JSON file as their parent.
Falls back to fuzzy substring matching, then returns the input
unchanged if no alias is known.
"""
key = brand.lower().strip()
direct = BRAND_ALIASES.get(key)
if direct:
return direct
for alias, parent in BRAND_ALIASES.items():
if alias in key or key in alias:
return parent
return brand
def get_known_sub_brands(brand: str) -> list[str]:
"""Return known sub-brand/product names for a given brand from BRAND_ALIASES.
This is used to enrich LLM prompts so the model knows what kinds of
products a brand actually makes (e.g. for 'parle' it returns
['biscuits', 'hide & seek', 'milano', 'krackjack', ...]).
Returns an empty list when the brand has no aliases registered.
"""
canonical = resolve_parent_brand(brand).lower().strip()
known: list[str] = []
seen: set[str] = set()
for alias, parent in BRAND_ALIASES.items():
if parent.lower() == canonical and alias.startswith(canonical):
rest = alias[len(canonical):].strip()
if rest and rest not in seen:
known.append(rest)
seen.add(rest)
return known

View File

@@ -0,0 +1,222 @@
"""
Single source of truth for the product-category taxonomy used by this
catalog: what keywords identify each category, and what "generic noun"
should be used to describe a product of that category.
Why this file exists
---------------------
Two different parts of the system used to guess categories independently:
1. `catalog_engine.py` had its own small keyword->category heuristic used
when the LLM left a product `Uncategorized`.
2. The RAG chat/search path had NO category awareness at all - a query
like "recommend biscuits with low sugar" was answered purely by
embedding similarity, with no guardrail that would stop a `Snacks`
product (e.g. "ITC Bingo Korean Style") from being recommended for a
biscuit query just because its (LLM-generated) description happened
to use the word "biscuit" loosely.
Centralizing the keyword map here means both sides agree on the same
taxonomy, and it becomes the basis for:
- `detect_category_from_text()` - used by the RAG query pipeline to
figure out which category (if any) the user is actually asking
about, so retrieval can be scoped to that category.
- `category_matches()` - a normalized equality check used
as a safety net after retrieval, so a product that slipped through
with the wrong category (e.g. stale/legacy embeddings) never
reaches the LLM or the user.
- `sanitize_category_language()` - used at ingestion time to strip
cross-category noun leakage out of LLM-generated descriptions
(e.g. rewriting "a crispy, savory biscuit" to "a crispy, savory
snack" for a product actually categorized as `Snacks`), which is
the root cause of the embedding pollution described above.
"""
from __future__ import annotations
import re
from typing import Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# Canonical taxonomy
# ---------------------------------------------------------------------------
# Ordered by priority: when a query or description matches keywords from
# more than one category, the category that appears EARLIER in this dict
# wins. Narrower / more specific product-type categories are listed before
# broader or modifier-like ones (e.g. "Biscuits & Cookies" before
# "Chocolates", so "chocolate biscuits" resolves to biscuits, not
# chocolates).
#
# `keywords` - phrases that identify this category (checked as whole
# words/phrases, case-insensitive, longest match wins
# within a category).
# `generic_term` - the neutral singular noun used to describe a product of
# this category when sanitizing cross-category language
# out of a generated description.
CATEGORY_REGISTRY: List[Dict[str, object]] = [
{"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", "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", "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", "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", "sanitizer"], "generic_term": "antiseptic product"},
]
# All known canonical category names, in priority order.
ALL_CATEGORIES: List[str] = [entry["category"] for entry in CATEGORY_REGISTRY]
def _normalize(text: Optional[str]) -> str:
"""Lowercase and strip everything except letters/digits/spaces, so
'Biscuits & Cookies' and 'biscuits and cookies' compare equal."""
if not text:
return ""
text = text.lower().replace("&", " and ")
text = re.sub(r"[^a-z0-9\s]", " ", text)
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)."""
if not text:
return []
lower = text.lower()
matches: List[Tuple[str, str, int]] = []
for entry in CATEGORY_REGISTRY:
category = entry["category"]
for kw in entry["keywords"]:
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
def detect_category_from_text(text: str) -> Optional[str]:
"""Infer a single canonical category from free text (typically a user
query), or None if no category-identifying keyword is present.
When multiple categories match, the one listed earliest in
`CATEGORY_REGISTRY` wins (see module docstring); ties within that are
broken by the longest matched keyword.
"""
matches = _find_matches(text)
if not matches:
return None
priority = {cat: i for i, cat in enumerate(ALL_CATEGORIES)}
# Lower priority index = higher priority (earlier in registry).
matches.sort(key=lambda m: (priority.get(m[0], 999), -m[2]))
return matches[0][0]
def category_matches(product_category: Optional[str], target_category: Optional[str]) -> bool:
"""Normalized equality check between a product's stored category and a
target (e.g. detected-from-query) category. Used as a safety net after
retrieval so mismatched-category products never reach the user, even
if they scored well on raw embedding similarity."""
if not target_category:
return True
return _normalize(product_category) == _normalize(target_category)
def sanitize_category_language(description: str, product_category: Optional[str]) -> str:
"""Strip cross-category *identity claims* out of a generated description.
Blanket keyword replacement turned out to be too aggressive: FMCG copy
routinely mentions other categories as flavors or ingredients ("combines
the taste of cheese and crackers"), and replacing those reads as
nonsense even though they were never the source of the retrieval bug.
What actually caused a `Snacks` product to surface for a "biscuits"
query was the description *asserting an identity* it doesn't have -
"ITC Bingo Korean Style is a crispy, savory **biscuit**...". So this
function only rewrites that specific LLM-template shape:
"... is/are/was/were [a/an/the] [up to 4 words] KEYWORD ..."
e.g. "is a crispy, savory biscuit" -> "is a crispy, savory snack"
A flavor mention like "taste of cheese and crackers" doesn't match this
shape, so it's left alone rather than mangled into "taste of cracker".
Note: this deliberately does NOT try to repair descriptions that are
wrong in a deeper way (an entire sentence describing the wrong product
domain, e.g. a snack described as being "engineered to deliver powerful
cleaning") - a regex substitution can't safely rewrite a whole
hallucinated clause. Retrieval-time category filtering (see
`rag_service._filter_to_category`) is what actually protects the RAG
answers from that class of error, since it filters on the product's
stored `category` column rather than on description text; this
function only cleans up isolated noun-level mislabeling.
"""
if not description:
return description
own_category = product_category or ""
own_entry = next((e for e in CATEGORY_REGISTRY if e["category"] == own_category), None)
replacement = own_entry["generic_term"] if own_entry else _generic_fallback_term(own_category)
cleaned = description
for entry in CATEGORY_REGISTRY:
if entry["category"] == own_category:
continue # a product's own category words are never "leakage"
for kw in sorted(entry["keywords"], key=len, reverse=True):
kw_pattern = re.escape(kw)
pattern = re.compile(
r"\b(?:is|are|was|were)\s+(?:a|an|the)?\s*(?:[A-Za-z]+[,\s]+){0,4}?(" + kw_pattern + r")\b",
re.IGNORECASE,
)
def _replace(match: "re.Match[str]") -> str:
word = match.group(1)
repl_word = replacement[0].upper() + replacement[1:] if word[0].isupper() else replacement
rel_start = match.span(1)[0] - match.start()
return match.group(0)[:rel_start] + repl_word
cleaned = pattern.sub(_replace, cleaned)
return cleaned
def _generic_fallback_term(category: Optional[str]) -> str:
if not category or category.strip().lower() in ("", "uncategorized", "general"):
return "product"
return category.split("&")[0].split("-")[0].strip().lower() or "product"

View File

@@ -0,0 +1,93 @@
"""
Thin I/O orchestration for Feature 3 (Dynamic Discount Prediction).
Builds the live feature row for a given (store, product) from current
DB state, runs it through the trained model, and logs the prediction to
`discount_history` for the analytics/audit trail Feature 10 asks for.
"""
from __future__ import annotations
import logging
from datetime import date
from typing import Dict, List, Optional
import pandas as pd
from app.intelligence import features as F
from app.intelligence.discount_model import discount_predictor, FEATURE_COLUMNS
from app.services import store_db
logger = logging.getLogger(__name__)
def _build_feature_row(store_row: Dict, order_items: pd.DataFrame, as_of: date) -> Dict[str, float]:
vel7 = F.sales_velocity(order_items, store_row["store_id"], store_row["brand"], store_row["image_id"], as_of, 7)
vel30 = F.sales_velocity(order_items, store_row["store_id"], store_row["brand"], store_row["image_id"], as_of, 30)
# Real demand/popularity scores aren't wired to this call path yet
# (they'd come from popularity_model/trending_model given engagement
# data this endpoint doesn't have) - use recent velocity normalized
# to a 0-100-ish scale as a reasonable proxy so the discount model
# still gets a meaningful demand/popularity signal rather than a
# hardcoded constant.
demand_proxy = min(100.0, vel30["units_per_day"] * 20)
return {
"stock_ratio": F.stock_ratio(store_row["available_stock"], store_row["reorder_level"]),
"days_of_cover": F.days_of_cover(store_row["available_stock"], max(vel30["units_per_day"], 0.05)),
"units_per_day_7": vel7["units_per_day"],
"units_per_day_30": vel30["units_per_day"],
"demand_score": demand_proxy,
"popularity_score": demand_proxy,
"days_to_expiry": F.days_to_expiry(store_row.get("category"), 0),
"is_festive_season": F.is_festive_season(as_of),
"category_freq": 0.15, # neutral default for a single-row live prediction (no full-catalog context here)
"store_tier_encoded": F.encode_store_tier(store_row.get("store_tier", "standard")),
"price_position": (store_row["selling_price"] / store_row["mrp"]) if store_row.get("mrp") else 1.0,
}
def predict_discount_for_product(store_id: str, brand: str, image_id: str, log_result: bool = True) -> Optional[Dict]:
rows = store_db.get_store_products(store_id, limit=1000)
match = next((r for r in rows if r["brand"] == brand and r["image_id"] == image_id), None)
if not match:
return None
order_items = store_db.get_order_items_df(store_id=store_id)
feature_row = _build_feature_row(match, order_items, date.today())
pred = discount_predictor.predict_one(feature_row, original_price=float(match["selling_price"]))
result = {
"store_id": store_id, "brand": brand, "image_id": image_id,
"original_price": float(match["selling_price"]), "discount_pct": pred.discount_pct,
"final_price": pred.final_price, "savings": pred.savings, "model_version": "v1",
}
if log_result:
store_db.log_discounts([{**result, "features": feature_row}])
return result
def predict_discounts_for_store(store_id: str, log_result: bool = True) -> List[Dict]:
"""Batch version - scores every product in a store in one pass
(much faster than calling predict_discount_for_product per item
since it builds all velocity features from a single order_items
fetch, and does one bulk model.predict() call instead of N)."""
rows = store_db.get_store_products(store_id, limit=5000)
if not rows:
return []
order_items = store_db.get_order_items_df(store_id=store_id)
as_of = date.today()
feature_rows = [_build_feature_row(r, order_items, as_of) for r in rows]
df = pd.DataFrame(feature_rows)
df["selling_price"] = [r["selling_price"] for r in rows]
scored = discount_predictor.predict_batch(df)
results = []
log_rows = []
for r, (_, s) in zip(rows, scored.iterrows()):
item = {
"store_id": store_id, "brand": r["brand"], "image_id": r["image_id"],
"original_price": float(r["selling_price"]), "discount_pct": float(s["discount_pct"]),
"final_price": float(s["final_price"]), "savings": float(s["savings"]), "model_version": "v1",
}
results.append(item)
log_rows.append({**item, "features": {c: float(s[c]) for c in FEATURE_COLUMNS}})
if log_result and log_rows:
store_db.log_discounts(log_rows)
return results

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from typing import List, Optional, TYPE_CHECKING
from app.infrastructure.settings import EMBEDDINGS_MODEL
if TYPE_CHECKING: # pragma: no cover - typing only, no runtime cost
from sentence_transformers import SentenceTransformer
_model_singleton: Optional["SentenceTransformer"] = None
def get_device() -> str:
"""Prefer CUDA if available, otherwise CPU.
Imports torch lazily: on an 8GB RAM / CPU-only laptop there is no
benefit to importing torch (and paying its startup/memory cost) until
an embedding is actually requested, so the FastAPI process can boot
and answer /api/health almost instantly.
"""
import torch # local import - see docstring
return "cuda" if torch.cuda.is_available() else "cpu"
def get_embedding_model() -> "SentenceTransformer":
global _model_singleton
if _model_singleton is None:
from sentence_transformers import SentenceTransformer # local import - see get_device()
device = get_device()
_model_singleton = SentenceTransformer(EMBEDDINGS_MODEL, device=device)
return _model_singleton
def embed_texts(texts: List[str]) -> List[List[float]]:
"""Embed a batch of texts into normalized 384-dim vectors (MiniLM-L6-v2).
Normalized so that pgvector's cosine-distance operator (`<=>`) behaves
consistently for the RAG retrieval step in `app.services.vector_store`.
"""
if not texts:
return []
model = get_embedding_model()
embeddings = model.encode(
texts,
batch_size=32,
normalize_embeddings=True,
convert_to_numpy=True,
show_progress_bar=False,
)
return embeddings.tolist()

View File

@@ -0,0 +1,538 @@
"""
Product image discovery service.
Layered, validated pipeline of OPEN-SOURCE sources, cheapest/most-reliable
first (important on an 8GB RAM / no-GPU machine: avoid launching a real
browser unless every cheaper structured source has already failed):
1. Open Food Facts / Open Beauty Facts / Open Products Facts
- Free, open, community-maintained product databases with real photos.
- No API key, no quota, no headless browser needed (single HTTP GET).
- Covers most Indian FMCG: food, snacks, dairy AND personal-care/
household items (toothpaste, soap, shampoo, etc.) via the sibling
"Beauty Facts" and "Products Facts" projects.
- Also returns a real `quantity` field (e.g. "200 g", "1 L") when
available, which `find_product_quantity_openfacts()` exposes
separately so callers can ground size-variant guesses in real
packaging data instead of an LLM's guess.
2. Wikimedia Commons (new)
- Free, open media repository (the same one behind Wikipedia), no API
key required. Good secondary source especially for large,
well-established Indian/global FMCG brands (Horlicks, Dettol,
Colgate, etc.) that have freely-licensed packaging photos uploaded.
3. DuckDuckGo Images via the `ddgs` package
- No API key requirement, pure Python/HTTP (no browser process).
4. Google Custom Search Engine (optional - only used if the user has
configured GOOGLE_API_KEY / GOOGLE_CSE_ID).
5. Playwright-rendered Bing Image Search (LAST resort only)
- Replaces the old Node.js/Crawlee subprocess scripts (see
playwright_image_fallback.py for why). Launches a real headless
Chromium tab so it can deal with Bing's JS-gated results page far
more reliably than a raw `requests` HTML scrape, but only runs if
every cheaper source above produced nothing, since spinning up a
browser is the slowest/heaviest option here.
Every URL that makes it into the final list is passed through
`validate_image_url_live`, a lightweight ranged-GET check that confirms it
actually resolves to real image bytes above a minimum size - this is what
stops garbage/placeholder/expired URLs from reaching the S3 upload step
and failing there silently.
"""
from typing import Optional, List
import requests
from urllib.parse import urlparse
import json
import logging
import subprocess
import sys
from pathlib import Path
from app.infrastructure.settings import (
USE_GOOGLE_CSE, GOOGLE_API_KEY, GOOGLE_CSE_ID,
USE_DDG_IMAGES, USE_OPEN_FACTS, USE_WIKIMEDIA, USE_PLAYWRIGHT_FALLBACK,
MIN_IMAGE_BYTES,
)
logger = logging.getLogger(__name__)
_BROWSER_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
# Open *Facts projects share the same API shape; we query all three so both
# food (Open Food Facts) and non-food FMCG like toothpaste/soap (Open Beauty
# Facts / Open Products Facts) are covered.
OPEN_FACTS_HOSTS = [
"world.openfoodfacts.org",
"world.openbeautyfacts.org",
"world.openproductsfacts.org",
]
# Module-level flag so we only ever print the "Playwright not installed"
# hint once per process, instead of once per product.
_playwright_warning_shown = False
# ---------------------------------------------------------------------------
# 1. Open Food/Beauty/Products Facts - primary source
# ---------------------------------------------------------------------------
def _query_openfacts(query: str, max_results: int) -> list:
"""Raw Open*Facts product list for a query string (internal helper)."""
if not query:
return []
for host in OPEN_FACTS_HOSTS:
try:
resp = requests.get(
f"https://{host}/cgi/search.pl",
params={
"search_terms": query,
"search_simple": 1,
"action": "process",
"json": 1,
"page_size": max_results,
},
headers={"User-Agent": _BROWSER_UA},
timeout=12,
)
if resp.status_code != 200:
continue
data = resp.json()
products = data.get("products", [])
if products:
return products
except Exception as e:
logger.debug(f"Open*Facts lookup failed on {host}: {e}")
continue
return []
def find_images_openfacts(title: str, brand: Optional[str] = None, max_results: int = 20) -> list:
"""Query the Open *Facts family of open product databases for real
product photos. No API key required. Falls back from a brand+title
query to a title-only query if the combined query is too specific to
match anything (small/regional brand name variants are a common case)."""
if not USE_OPEN_FACTS:
return []
query = f"{brand or ''} {title or ''}".strip()
if not query:
return []
products = _query_openfacts(query, max_results)
if not products and brand and title:
# Combined "brand + title" query found nothing - retry with just
# the title, since Open*Facts' free-text search is exact-ish and
# brand naming conventions vary (e.g. "Dettol" vs "Reckitt Dettol").
products = _query_openfacts(title, max_results)
urls: List[str] = []
for product in products:
for key in ("image_front_url", "image_url", "image_front_small_url"):
img = product.get(key)
if img and str(img).startswith("http"):
urls.append(img)
break
return urls[:max_results]
def find_product_quantity_openfacts(title: str, brand: Optional[str] = None) -> Optional[str]:
"""Return the real packaging quantity (e.g. '200 g', '1 l') reported by
Open*Facts for the best-matching product, if any. This lets callers
ground size-variant guesses in real data instead of trusting the LLM's
guess for size labels."""
if not USE_OPEN_FACTS:
return None
query = f"{brand or ''} {title or ''}".strip()
products = _query_openfacts(query, 1)
if not products and brand and title:
products = _query_openfacts(title, 1)
if not products:
return None
qty = products[0].get("quantity")
return str(qty).strip() if qty else None
# ---------------------------------------------------------------------------
# 2. Wikimedia Commons - free/open media repository, no API key
# ---------------------------------------------------------------------------
def find_images_wikimedia(title: str, brand: Optional[str] = None, max_results: int = 20) -> list:
"""Search Wikimedia Commons (the open media library behind Wikipedia)
for product/packaging photos. Good secondary source for established
brands; complements Open*Facts which leans more food/grocery."""
if not USE_WIKIMEDIA:
return []
query = f"{brand or ''} {title or ''}".strip()
if not query:
return []
try:
# Add exclusion terms to avoid non-product results (e.g. "Sprite" the
# car, "Tiger" the animal, "Apple" the fruit) when searching for a
# branded packaged-good product. The gsrsearch parameter supports
# MediaWiki's standard search syntax including the minus (-) prefix
# for terms to exclude.
exclusion_terms = "-car -vehicle -automotive -motorsport -racing -motorcycle -bike -people -person -portrait -animal -pet -dog -cat -bird -fish -landscape -nature -tour -travel -building -architecture -sport -game -flower -rose -floral -petal -bouquet -botanical -plant -garden -tree -herb"
search_query = f"{query} {exclusion_terms} filetype:bitmap"
resp = requests.get(
"https://commons.wikimedia.org/w/api.php",
params={
"action": "query",
"format": "json",
"generator": "search",
"gsrsearch": search_query,
"gsrlimit": max_results,
"gsrnamespace": 6, # File: namespace
"prop": "imageinfo",
"iiprop": "url|size",
"iiurlwidth": 800,
},
headers={"User-Agent": _BROWSER_UA},
timeout=10,
)
if resp.status_code != 200:
return []
pages = resp.json().get("query", {}).get("pages", {})
urls = []
for page in pages.values():
info_list = page.get("imageinfo", [])
if not info_list:
continue
info = info_list[0]
url = info.get("thumburl") or info.get("url")
if url and url.startswith("http"):
urls.append(url)
return urls[:max_results]
except Exception as e:
logger.debug(f"Wikimedia Commons search error: {e}")
return []
# ---------------------------------------------------------------------------
# 3. DuckDuckGo Images (pure Python, no browser process)
# ---------------------------------------------------------------------------
def find_all_image_urls_ddg(title: str, brand: Optional[str] = None, max_results: int = 20) -> list:
"""Search DuckDuckGo for product images via the `ddgs` package."""
if not USE_DDG_IMAGES:
return []
query = f"{brand} {title}".strip()
if not query:
return []
try:
from ddgs import DDGS
except ImportError:
logger.debug("DuckDuckGo image search unavailable: 'ddgs' package not installed")
return []
for backend in ("duckduckgo", "auto"):
try:
with DDGS(timeout=15) as ddgs:
results = ddgs.images(
query,
region="in-en",
safesearch="off",
max_results=max_results,
backend=backend,
)
image_urls = [
r.get('image') for r in results
if r.get('image') and str(r.get('image')).startswith('http')
]
if image_urls:
return image_urls
except Exception as e:
logger.debug(f"DuckDuckGo image search error (backend={backend}): {e}")
continue
return []
def find_all_image_urls_google_cse(title: str, brand: Optional[str] = None) -> list:
"""Search Google Custom Search Engine for product images (used only if
the user has configured GOOGLE_API_KEY / GOOGLE_CSE_ID - optional)."""
if not USE_GOOGLE_CSE or not GOOGLE_API_KEY or not GOOGLE_CSE_ID:
return []
try:
query = f"{brand} {title}".strip()
resp = requests.get(
"https://www.googleapis.com/customsearch/v1",
params={
'key': GOOGLE_API_KEY, 'cx': GOOGLE_CSE_ID, 'q': query,
'searchType': 'image', 'num': 10,
},
timeout=15,
)
resp.raise_for_status()
items = resp.json().get('items', [])
return [item['link'] for item in items if 'link' in item]
except Exception as e:
logger.debug(f"Google CSE error: {e}")
return []
# ---------------------------------------------------------------------------
# 4. Playwright (Python) Bing Image Search - LAST resort only.
#
# Replaces the old Node.js/Crawlee subprocess scripts. See
# playwright_image_fallback.py for the full rationale. Runs in a fully
# isolated OS subprocess so it can never conflict with Streamlit's asyncio
# event loop, and has a short, bounded timeout instead of the old 120s+60s
# double-timeout chain.
# ---------------------------------------------------------------------------
def find_images_playwright(title: str, brand: Optional[str] = None, max_results: int = 20) -> list:
global _playwright_warning_shown
if not USE_PLAYWRIGHT_FALLBACK:
return []
query = f"{brand or ''} {title or ''}".strip()
if not query:
return []
try:
result = subprocess.run(
[sys.executable, "-m", "app.services.playwright_image_fallback", query, str(max_results)],
cwd=str(Path(__file__).parent.parent.parent),
capture_output=True,
text=True,
timeout=30,
)
except subprocess.TimeoutExpired:
logger.debug("Playwright image fallback timed out")
return []
except Exception as e:
logger.debug(f"Playwright image fallback failed to start: {e}")
return []
if "PLAYWRIGHT_NOT_INSTALLED" in (result.stderr or "") or "PLAYWRIGHT_LAUNCH_FAILED" in (result.stderr or ""):
if not _playwright_warning_shown:
logger.warning(
"Playwright browser fallback unavailable - run `pip install playwright && "
"playwright install chromium` to enable it as a last-resort image source. "
"Continuing with Open*Facts / Wikimedia / DuckDuckGo only."
)
_playwright_warning_shown = True
return []
try:
out = (result.stdout or "").strip()
if out.startswith("[") and out.endswith("]"):
return json.loads(out)
except json.JSONDecodeError:
pass
return []
# ---------------------------------------------------------------------------
# Validation: confirm a URL actually resolves to real image bytes before we
# let it through to the S3 upload step.
# ---------------------------------------------------------------------------
def validate_image_url_live(url: str, referer: Optional[str] = None, timeout: int = 8) -> bool:
"""Lightweight check that `url` resolves to real image content above a
minimum size threshold. Uses a ranged GET (not HEAD) because many sites
don't return reliable Content-Length on HEAD requests.
Tries twice: once with a Referer derived from the image's own domain
(helps with hotlink-protected CDNs that expect *some* same-site
Referer), and - if that's rejected - once with no Referer at all (some
sites do the opposite and reject requests that carry a Referer header
pretending to be same-site when it isn't). This two-step retry recovers
a meaningful fraction of URLs that single-strategy validation used to
discard.
"""
if not url or not url.startswith("http"):
return False
parsed = urlparse(url)
same_site_referer = f"{parsed.scheme}://{parsed.netloc}/" if parsed.netloc else None
for attempt_referer in (referer or same_site_referer, None):
try:
headers = {
"User-Agent": _BROWSER_UA,
"Accept": "image/*,*/*;q=0.8",
}
if attempt_referer:
headers["Referer"] = attempt_referer
resp = requests.get(url, headers=headers, timeout=timeout, stream=True)
if resp.status_code != 200:
resp.close()
continue
content_type = resp.headers.get("content-type", "").lower()
if "image" not in content_type and not url.lower().endswith(
(".jpg", ".jpeg", ".png", ".webp", ".gif")
):
resp.close()
continue
chunk = resp.raw.read(MIN_IMAGE_BYTES + 1)
resp.close()
if len(chunk) >= MIN_IMAGE_BYTES:
return True
except Exception:
continue
return False
def _dedupe(urls: list) -> list:
seen = set()
out = []
for u in urls:
if u and u not in seen:
seen.add(u)
out.append(u)
return out
# ---------------------------------------------------------------------------
# Helpers for disambiguating search queries
# ---------------------------------------------------------------------------
# Product-type context hints for common ambiguous brand/product words
# that image search APIs tend to confuse with non-product content.
# Keyed by the ambiguous word (lowercase); value is the context term
# to append to the search query.
_AMBIQUITY_HINTS: dict[str, str] = {
"perk": "chocolate wafer",
"crunch": "chocolate wafer",
"boost": "drink supplement",
"active": "yogurt drink",
"sprite": "soft drink",
"tiger": "biscuit snack",
"apple": "fruit juice",
"refresh": "drink mouthwash",
"glow": "lotion skincare",
"white": "toothpaste dental",
"clean": "shampoo soap",
"cool": "mint gum candy",
"energy": "drink snack bar",
"good day": "biscuit cookies",
"happy": "chocolate candy",
"rich": "cream biscuit",
"dark": "chocolate bar",
"milk": "chocolate dairy",
"5 star": "chocolate bar",
"munch": "chocolate wafer",
"gems": "chocolate candy",
"treat": "chocolate candy",
"break": "biscuit cracker",
"digestive": "biscuit cracker",
"marie": "biscuit cookies",
"oreo": "biscuit cookies",
"bounce": "snack bar",
"soap": "bath soap bar",
"life": "cereal grain",
"special": "cereal biscuit",
"corn": "snack flakes",
"chocos": "breakfast cereal",
}
def _clean_search_title(title: str, brand: str | None = None) -> str:
"""Strip redundant brand prefix from title and add context hints for
ambiguous product names that confuse image search APIs.
Examples:
_clean_search_title("Cadbury Perk Crunch", "Cadbury Perk")
-> "Perk Crunch chocolate wafer"
_clean_search_title("Britannia Good Day Biscuits", "Britannia")
-> "Good Day Biscuits"
"""
clean = (title or "").strip()
if brand:
brand_lower = brand.lower().strip()
clean_lower = clean.lower()
if clean_lower.startswith(brand_lower):
clean = clean[len(brand):].strip()
if not clean:
clean = (title or "").strip()
# Append context hint for any ambiguous word in the cleaned title
clean_lower = clean.lower()
for word, hint in _AMBIQUITY_HINTS.items():
if word in clean_lower:
clean = f"{clean} {hint}"
break
return clean
# ---------------------------------------------------------------------------
# Public API (same signatures as before, so catalog_engine.py and
# downstream callers don't need to change)
# ---------------------------------------------------------------------------
def find_image_url(title: str, brand: Optional[str] = None, country_hint: Optional[str] = None) -> Optional[str]:
"""Get a single working, validated image URL."""
urls = find_all_image_urls(title, brand, country_hint)
return urls[0] if urls else None
def find_all_image_urls(title: str, brand: Optional[str] = None, country_hint: Optional[str] = None,
validate: bool = True, max_results: int = 24) -> list:
"""Get working image URLs by trying multiple open-source sources in
priority order (cheapest/most-reliable first), merging and validating
results. Only escalates to the Playwright browser fallback if every
cheaper structured/HTTP source above produced nothing at all."""
candidates: List[str] = []
# Strip redundant brand prefix from title to avoid repetitive queries
# like "Cadbury Perk Cadbury Perk Crunch".
search_title = _clean_search_title(title, brand)
candidates.extend(find_images_openfacts(search_title, brand, max_results))
if len(candidates) < max_results:
candidates.extend(find_images_wikimedia(search_title, brand, max_results))
if len(candidates) < max_results:
candidates.extend(find_all_image_urls_ddg(search_title, brand, max_results))
if len(candidates) < max_results:
candidates.extend(find_all_image_urls_google_cse(search_title, brand))
candidates = _dedupe(candidates)
if not validate:
if not candidates:
candidates = find_images_playwright(search_title, brand, max_results)
return candidates[:max_results]
validated = []
for url in candidates:
if validate_image_url_live(url):
validated.append(url)
if len(validated) >= max_results:
break
if not validated:
# Every cheap/structured source either returned nothing or returned
# only dead links - last resort: render a real Bing search page.
fallback_candidates = _dedupe(find_images_playwright(search_title, brand, max_results))
for url in fallback_candidates:
if validate_image_url_live(url):
validated.append(url)
if len(validated) >= max_results:
break
if validated:
return validated
# Validation killed everything (e.g. offline machine, or every host
# blocked the validation probe) - return unvalidated candidates rather
# than nothing, so the rest of the pipeline still has something to try.
if candidates:
logger.warning(f"No validated images for '{brand} {title}' - returning unvalidated candidates")
return candidates[:max_results]
def validate_image_url(url: Optional[str], title: str, brand: Optional[str] = None) -> Optional[str]:
"""Kept for backward compatibility with any existing callers."""
if not url or not url.startswith("http"):
return find_image_url(title, brand)
return url

View File

@@ -0,0 +1,180 @@
"""
Orchestrates training every model in `app/intelligence/` against real
seeded data, then refreshes the cached outputs (`trending_products`,
`discount_history`, `demand_forecast`) that the API's fast-path reads
depend on. Called from `scripts/train_ml_models.py` (CLI, primary) and
`/api/admin/store-intelligence/train` (convenience).
"""
from __future__ import annotations
import logging
from datetime import date, timedelta
from typing import Dict, List, Optional
import numpy as np
import pandas as pd
from app.intelligence import (
discount_model, engagement_simulation, forecasting, popularity_model,
purchase_propensity_model, store_performance_model, trending_model,
)
from app.services import store_db
from app.services.trending_service import compute_and_cache_trending
from app.services.discount_service import predict_discounts_for_store
logger = logging.getLogger(__name__)
ALL_MODELS = ["discount", "trending", "popularity", "forecast", "store_performance", "purchase_propensity"]
def _load_common_frames():
order_items = store_db.get_order_items_df()
orders = store_db.get_orders_df()
store_products = store_db.get_all_store_products_df()
return order_items, orders, store_products
def train_discount(order_items: pd.DataFrame, store_products: pd.DataFrame) -> Dict:
if store_products.empty:
return {"skipped": "no store products - run the seed step first"}
rng = np.random.default_rng(3)
sp = store_products.copy()
sp["days_since_stocked"] = rng.integers(0, 60, size=len(sp))
# Real velocity-derived demand/popularity proxy (same approach as the
# live single-product path in discount_service) so training features
# match what's used at inference time as closely as possible.
key = sp["brand"] + "||" + sp["image_id"]
units_30d = order_items[order_items["order_date"] >= pd.Timestamp(date.today() - timedelta(days=30))]
vel = (units_30d.groupby(units_30d["brand"] + "||" + units_30d["image_id"])["quantity"].sum() / 30) if not units_30d.empty else pd.Series(dtype=float)
sp["demand_score"] = key.map(vel).fillna(0.0).clip(upper=5) * 20
sp["popularity_score"] = sp["demand_score"]
frame = discount_model.build_training_frame(sp, order_items, date.today())
bundle = discount_model.train(frame)
return {"n_samples": bundle.n_samples, **bundle.extra}
def train_trending(order_items: pd.DataFrame, orders: pd.DataFrame) -> Dict:
if order_items.empty:
return {"skipped": "no order history - run the seed step first"}
as_of_dates = [date.today() - timedelta(days=d) for d in (0, 5, 10, 15, 20, 25, 30, 40, 50)]
frame = trending_model.build_training_frame(order_items, orders, as_of_dates)
if frame.empty:
return {"skipped": "not enough order history to build trend windows yet"}
bundle = trending_model.train(frame)
cached_counts = {}
for window in ("today", "weekly", "monthly"):
cached_counts[window] = compute_and_cache_trending(window)
return {"n_samples": bundle.n_samples, **bundle.extra, "cached_scopes": {k: len(v) for k, v in cached_counts.items()}}
def train_popularity(order_items: pd.DataFrame, store_products: pd.DataFrame) -> Dict:
if store_products.empty:
return {"skipped": "no store products - run the seed step first"}
products = store_products[["brand", "image_id"]].drop_duplicates()
orders_count = (
order_items.groupby(order_items["brand"] + "||" + order_items["image_id"])["quantity"].sum()
if not order_items.empty else pd.Series(dtype=float)
)
eng = engagement_simulation.simulate_engagement(products, orders_count)
frame = popularity_model.build_training_frame(eng)
bundle = popularity_model.train(frame)
return {"n_samples": bundle.n_samples, **bundle.extra}
def train_forecast(order_items: pd.DataFrame, store_products: pd.DataFrame) -> Dict:
if order_items.empty or store_products.empty:
return {"skipped": "no order history - run the seed step first"}
meta = store_products[["store_id", "brand", "image_id", "category", "store_tier"]].drop_duplicates()
as_of_dates = [date.today() - timedelta(days=d) for d in (0, 10, 20, 30, 40)]
frame = forecasting.build_training_frame(order_items, meta, as_of_dates)
if len(frame) < 10:
return {"skipped": f"only {len(frame)} training rows - need more order history"}
bundle = forecasting.train(frame)
# Refresh the demand_forecast cache for every (store, product) with
# enough history for a 7-day-ahead forecast.
forecast_rows = []
for _, row in meta.iterrows():
from app.intelligence.forecasting import _daily_series
series = _daily_series(order_items, row["store_id"], row["brand"], row["image_id"])
if len(series) < 14:
continue
from app.intelligence import features as F
feat = pd.DataFrame([{
"rolling_mean_7": series.tail(7).mean(), "rolling_mean_14": series.tail(14).mean(),
"rolling_mean_30": series.tail(30).mean(), **F.cyclical_month_features(date.today()),
**F.cyclical_dow_features(date.today()), "is_festive_season": F.is_festive_season(date.today()),
"category_freq": 0.15, "store_tier_encoded": F.encode_store_tier(row["store_tier"]),
}])
out = forecasting.demand_forecaster.forecast(feat)
forecast_rows.append({
"store_id": row["store_id"], "brand": row["brand"], "image_id": row["image_id"],
"forecast_avg_daily_units": round(float(out["forecast_avg_daily_units"].iloc[0]), 3),
"forecast_total_units": round(float(out["forecast_total_units"].iloc[0]), 3),
"horizon_days": 7, "model_version": "v1",
})
saved = store_db.save_demand_forecasts(forecast_rows)
return {"n_samples": bundle.n_samples, **bundle.extra, "forecasts_cached": saved}
def train_store_performance(orders: pd.DataFrame) -> Dict:
stores = store_db.list_stores()
if not stores or orders.empty:
return {"skipped": "no stores/orders - run the seed step first"}
stores_df = pd.DataFrame(stores)[["store_id", "tier"]]
daily_agg = store_performance_model.build_daily_store_aggregates(orders, stores_df)
disc_stub = pd.DataFrame(columns=["store_id", "date", "avg_discount_pct"])
frame = store_performance_model.build_training_frame(daily_agg, disc_stub)
if frame.empty:
return {"skipped": "not enough daily history per store yet"}
bundle = store_performance_model.train(frame)
return {"n_samples": bundle.n_samples, **bundle.extra}
def train_purchase_propensity(orders: pd.DataFrame) -> Dict:
if orders.empty:
return {"skipped": "no order history - run the seed step first"}
cutoffs = [date.today() - timedelta(days=d) for d in (14, 21, 28, 35, 42)]
frame = purchase_propensity_model.build_training_frame(orders, cutoffs)
if frame.empty:
return {"skipped": "not enough customer history yet"}
bundle = purchase_propensity_model.train(frame)
return {"n_samples": bundle.n_samples, **bundle.extra}
def train_all(models: Optional[List[str]] = None) -> Dict[str, Dict]:
models = models or ALL_MODELS
order_items, orders, store_products = _load_common_frames()
results: Dict[str, Dict] = {}
if "discount" in models:
logger.info("Training discount model...")
results["discount"] = train_discount(order_items, store_products)
if "trending" in models:
logger.info("Training trending model...")
results["trending"] = train_trending(order_items, orders)
if "popularity" in models:
logger.info("Training popularity model...")
results["popularity"] = train_popularity(order_items, store_products)
if "forecast" in models:
logger.info("Training demand forecast model...")
results["forecast"] = train_forecast(order_items, store_products)
if "store_performance" in models:
logger.info("Training store performance model...")
results["store_performance"] = train_store_performance(orders)
if "purchase_propensity" in models:
logger.info("Training purchase propensity model...")
results["purchase_propensity"] = train_purchase_propensity(orders)
# Refresh discount_history for every store now that the discount
# model is (re)trained, so /stores/{id}/discounts has fresh cached
# data immediately instead of computing on first request.
if "discount" in models and "skipped" not in results.get("discount", {}):
for store in store_db.list_stores():
try:
predict_discounts_for_store(store["store_id"])
except Exception as e: # noqa: BLE001
logger.warning("Failed to refresh discounts for %s: %s", store["store_id"], e)
return results

View File

@@ -0,0 +1,61 @@
"""
"Healthier Alternatives" - for a given product, surface other products
in the same category with a meaningfully better health_score.
Distinct from Feature 8 "Similar Products based on Nutrition"
(`nutrition_similarity.py`, which finds the closest nutrition profile
regardless of direction): this deliberately searches for something
*better*, not just *similar*, which is why it's category-scoped and
ranked by health_score improvement rather than by distance.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from app.services import nutrition_db
MIN_HEALTH_SCORE_DELTA = 8.0 # don't suggest an "alternative" that isn't meaningfully better
def find_alternatives(brand: str, image_id: str, top_k: int = 5) -> List[Dict[str, Any]]:
facts = nutrition_db.get_nutrition_facts(brand, image_id)
insights = nutrition_db.get_nutrition_insights(brand, image_id)
if not facts or facts.get("data_status") == "unavailable" or not insights or insights.get("health_score") is None:
return []
category = facts.get("category")
current_score = insights["health_score"]
if not category:
return []
candidates = nutrition_db.query_products(
sort_by="health_score", order="desc", category=category, limit=50,
)
ranked = []
for c in candidates:
if c["brand"] == brand and c["image_id"] == image_id:
continue
cand_score = c.get("health_score")
if cand_score is None:
continue
delta = cand_score - current_score
if delta < MIN_HEALTH_SCORE_DELTA:
continue
reason_bits = [f"{delta:.0f} points higher health score"]
if facts.get("total_sugar_g") is not None and c.get("total_sugar_g") is not None and c["total_sugar_g"] < facts["total_sugar_g"]:
reason_bits.append("lower sugar")
if facts.get("sodium_mg") is not None and c.get("sodium_mg") is not None and c["sodium_mg"] < facts["sodium_mg"]:
reason_bits.append("lower sodium")
if facts.get("protein_g") is not None and c.get("protein_g") is not None and c["protein_g"] > facts["protein_g"]:
reason_bits.append("more protein")
ranked.append({
"brand": c["brand"], "image_id": c["image_id"], "product_name": c.get("product_name"),
"health_score_delta": round(delta, 1), "reason": ", ".join(reason_bits),
})
ranked.sort(key=lambda r: r["health_score_delta"], reverse=True)
top = ranked[:top_k]
if top:
nutrition_db.save_healthy_alternatives(brand, image_id, top)
return top

View File

@@ -0,0 +1,46 @@
"""
Feature 9: "Nutrition Analytics". Thin service layer over the SQL
aggregations in `nutrition_db.py` - mirrors how `analytics_service.py`
sits over `store_db.py` for the Store Intelligence analytics page.
"""
from __future__ import annotations
from typing import Any, Dict
from app.services import nutrition_db
def get_leaderboards(limit: int = 10) -> Dict[str, Any]:
return {
"most_nutritious": nutrition_db.top_by_nutrient("nutrition_score", limit, ascending=False),
"highest_protein": nutrition_db.top_by_nutrient("protein", limit, ascending=False),
"highest_fiber": nutrition_db.top_by_nutrient("fiber", limit, ascending=False),
"lowest_sugar": nutrition_db.top_by_nutrient("sugar", limit, ascending=True),
"highest_calcium": nutrition_db.top_by_nutrient("calcium", limit, ascending=False),
"highest_iron": nutrition_db.top_by_nutrient("iron", limit, ascending=False),
"highest_vitamin_c": nutrition_db.top_by_nutrient("vitamin_c", limit, ascending=False),
"lowest_sodium": nutrition_db.top_by_nutrient("sodium", limit, ascending=True),
}
def get_brand_category_rankings(limit: int = 10) -> Dict[str, Any]:
return {
"healthiest_brands": nutrition_db.healthiest_brands(limit),
"healthiest_categories": nutrition_db.healthiest_categories(limit),
}
def get_distribution() -> Dict[str, Any]:
return {
"health_score_distribution": nutrition_db.score_distribution(),
"store_healthy_distribution": nutrition_db.store_healthy_distribution(),
"enrichment_status": nutrition_db.enrichment_status_counts(),
}
def get_full_dashboard(limit: int = 10) -> Dict[str, Any]:
return {
"leaderboards": get_leaderboards(limit),
"rankings": get_brand_category_rankings(limit),
"distribution": get_distribution(),
}

View File

@@ -0,0 +1,356 @@
"""
Feature 1/4/15: verified nutrition-data retrieval.
This is the "R" in the RAG approach the spec asks for: every nutrient
value that ends up in `nutrition_facts` comes from here, and everything
here comes from Open Food Facts (https://world.openfoodfacts.org) - a
crowdsourced-from-real-product-labels, openly licensed food database,
already used elsewhere in this codebase as a trusted source (see
`USE_OPEN_FACTS` in settings.py and `image_search.py`'s product-photo
lookup, which queries the same API family).
HARD RULE (Feature 15): this module NEVER estimates, interpolates, or
invents a nutrient value. If Open Food Facts doesn't return a field for
a matched product, that field stays `None` all the way through to the
API response - it does not get filled in with a category average, an
LLM guess, or a zero. If no confident product match is found at all,
`fetch_verified_nutrition` returns a `data_status="unavailable"` shell
and nothing downstream is allowed to treat that as "no nutrients" (zero
of everything) rather than "we don't know".
No LLM call happens anywhere in this file.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from difflib import SequenceMatcher
from typing import Any, Dict, List, Optional
import requests
from app.infrastructure.settings import USE_OPEN_FACTS, REQUEST_TIMEOUT_SECONDS
logger = logging.getLogger(__name__)
OFF_HOST = "world.openfoodfacts.org"
_BROWSER_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 KiranaAI-NutritionModule/1.0"
)
# Below this fuzzy name-match score, a candidate is not considered a
# confident enough match to attach real nutrient numbers to - it's
# better to show "Nutrition data unavailable" than to attach the wrong
# product's numbers. Configurable, not a magic untouchable constant.
MIN_MATCH_CONFIDENCE = 0.32
# Categories in this FMCG catalog that are never food (no nutrition
# panel exists), so we skip the network call entirely instead of
# generating noisy near-misses.
NON_FOOD_KEYWORDS = (
"soap", "detergent", "shampoo", "toothpaste", "cosmetic", "deodorant",
"diaper", "sanitary", "cleaner", "disinfectant", "battery", "stationery",
"utensil", "cookware", "appliance", "electronics", "toy",
)
# OFF `nutriments` values are normalized to grams per 100g/serving
# (except energy, which OFF already reports in kcal directly under the
# `-kcal` keys). This maps our flat schema column -> (off_key, output_unit).
_NUTRIENT_MAP: List[tuple] = [
("calories_kcal", "energy-kcal", "kcal_direct"),
("protein_g", "proteins", "g"),
("carbohydrates_g", "carbohydrates", "g"),
("total_sugar_g", "sugars", "g"),
("added_sugar_g", "added-sugars", "g"),
("dietary_fiber_g", "fiber", "g"),
("total_fat_g", "fat", "g"),
("saturated_fat_g", "saturated-fat", "g"),
("trans_fat_g", "trans-fat", "g"),
("cholesterol_mg", "cholesterol", "mg_from_g"),
("sodium_mg", "sodium", "mg_from_g"),
("potassium_mg", "potassium", "mg_from_g"),
("calcium_mg", "calcium", "mg_from_g"),
("iron_mg", "iron", "mg_from_g"),
("magnesium_mg", "magnesium", "mg_from_g"),
("zinc_mg", "zinc", "mg_from_g"),
("vitamin_a_mcg", "vitamin-a", "mcg_from_g"),
("vitamin_c_mg", "vitamin-c", "mg_from_g"),
("vitamin_d_mcg", "vitamin-d", "mcg_from_g"),
("vitamin_e_mg", "vitamin-e", "mg_from_g"),
("omega_3_g", "omega-3-fat", "g"),
("omega_6_g", "omega-6-fat", "g"),
]
# Extra nutrients worth keeping (individual B vitamins, trace minerals)
# that don't have a flat column - captured into `extended_nutrients`.
_EXTENDED_NUTRIENT_KEYS = [
("vitamin-b1", "Vitamin B1 (Thiamine)", "mg_from_g"),
("vitamin-b2", "Vitamin B2 (Riboflavin)", "mg_from_g"),
("vitamin-b6", "Vitamin B6", "mg_from_g"),
("vitamin-b9", "Vitamin B9 (Folate)", "mcg_from_g"),
("vitamin-b12", "Vitamin B12", "mcg_from_g"),
("pp", "Vitamin B3 (Niacin)", "mg_from_g"),
("phosphorus", "Phosphorus", "mg_from_g"),
("iodine", "Iodine", "mcg_from_g"),
("salt", "Salt", "g"),
]
def _convert(value: Optional[float], kind: str) -> Optional[float]:
if value is None:
return None
try:
v = float(value)
except (TypeError, ValueError):
return None
if kind == "g" or kind == "kcal_direct":
return round(v, 3)
if kind == "mg_from_g":
return round(v * 1000, 3)
if kind == "mcg_from_g":
return round(v * 1_000_000, 3)
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, 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}/api/v2/search",
params={
"search_terms": 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 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_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
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]:
tags = candidate.get("allergens_tags") or []
out = []
for tag in tags:
name = tag.split(":")[-1].replace("-", " ").strip()
if name:
out.append(name.title())
return sorted(set(out))
def _build_extended_nutrients(nutriments: dict, suffix: str) -> Dict[str, Any]:
out: Dict[str, Any] = {}
for off_key, label, kind in _EXTENDED_NUTRIENT_KEYS:
raw = nutriments.get(f"{off_key}_{suffix}")
value = _convert(raw, kind)
if value is not None:
unit = "mg" if "mg" in kind else ("mcg" if "mcg" in kind else "g")
out[label] = {"value": value, "unit": unit}
return out
def _build_flat_fields(nutriments: dict, suffix: str) -> Dict[str, Any]:
out: Dict[str, Any] = {}
for field_name, off_key, kind in _NUTRIENT_MAP:
raw = nutriments.get(f"{off_key}_{suffix}")
out[field_name] = _convert(raw, kind)
# Sodium fallback: OFF products more often report salt than sodium.
# Salt (g) -> sodium (mg) is a fixed, published conversion factor
# (sodium = salt / 2.5), not an estimate of the nutrient itself.
if out.get("sodium_mg") is None:
salt = nutriments.get(f"salt_{suffix}")
if salt is not None:
try:
out["sodium_mg"] = round((float(salt) / 2.5) * 1000, 3)
except (TypeError, ValueError):
pass
# Calories fallback: use energy_100g (kJ) -> kcal only if the
# kcal field itself wasn't provided directly.
if out.get("calories_kcal") is None:
kj = nutriments.get(f"energy_{suffix}")
if kj is not None:
try:
out["calories_kcal"] = round(float(kj) / 4.184, 1)
except (TypeError, ValueError):
pass
return out
def fetch_verified_nutrition(brand: str, title: str, category: str = "") -> Dict[str, Any]:
"""Look up verified nutrition data for one catalog product.
Always returns a dict with at least `data_status`. When
`data_status == "unavailable"`, every nutrient field is absent/None -
callers must render "Nutrition data unavailable" rather than
treating that as zero.
"""
now_iso = datetime.now(timezone.utc).isoformat()
unavailable = {"data_status": "unavailable", "fetched_at": now_iso}
if not USE_OPEN_FACTS:
return unavailable
if _looks_non_food(title, category):
return unavailable
candidates = _search_openfoodfacts(query=title, brand=brand, category=category)
if not candidates:
candidates = _search_openfoodfacts(query=title)
if not candidates:
return unavailable
scored = [(c, _match_confidence(brand, title, c)) for c in candidates]
scored.sort(key=lambda t: t[1], reverse=True)
best, confidence = scored[0]
if confidence < MIN_MATCH_CONFIDENCE:
return unavailable
nutriments = best.get("nutriments") or {}
if not nutriments:
return unavailable
per_100g = _build_flat_fields(nutriments, "100g")
per_serving = _build_flat_fields(nutriments, "serving")
has_any_value = any(v is not None for v in per_100g.values())
if not has_any_value:
return unavailable
extended = _build_extended_nutrients(nutriments, "100g")
code = best.get("code")
result: Dict[str, Any] = {
"data_status": "verified" if per_100g.get("calories_kcal") is not None else "partial",
"data_source": "openfoodfacts",
"source_ref": code,
"source_url": f"https://{OFF_HOST}/product/{code}" if code else None,
"match_confidence": round(confidence, 3),
"serving_size_g": _convert(best.get("serving_quantity"), "g"),
"serving_size_label": best.get("serving_size"),
"extended_nutrients": extended,
"per_serving": {k: v for k, v in per_serving.items() if v is not None},
"ingredients_text": (best.get("ingredients_text") or "").strip() or None,
"off_nutriscore": (best.get("nutriscore_grade") or "").strip().lower() or None,
"allergens": _extract_allergens(best),
"off_labels_tags": best.get("labels_tags") or [],
"off_ingredients_analysis_tags": best.get("ingredients_analysis_tags") or [],
"off_categories_tags": best.get("categories_tags") or [],
"fetched_at": now_iso,
}
result.update(per_100g)
return result

View File

@@ -0,0 +1,702 @@
"""
AI Nutritional Intelligence Module - persistence layer.
Follows the exact same conventions as `store_db.py` (raw psycopg, no ORM,
`_connect()` returns None and every function degrades to an empty/no-op
result when Postgres isn't reachable, `dict_row` cursor for read paths,
`_cast_numeric` for the Decimal->float conversion pandas/numpy chokes on)
so this module slots into the existing architecture instead of introducing
a second data-access pattern.
New tables, additive only - nothing here touches or drops any existing
`brand_*` table or any `store_*` table:
nutrition_facts - verified nutrition data (Feature 11 "Nutrition")
nutrition_insights - scores, insights, cautions, diet tags,
allergens, clustering (Feature 11
"Nutrition Insights")
nutrition_similar_products - cached ML nutritional-similarity output
(Feature 8)
nutrition_healthy_alternatives - cached healthier-alternative rankings
(Feature 2/7)
Products are referenced everywhere by the composite key (brand, image_id)
- the SAME natural key `store_db.py` already treats as globally unique
across brand tables (see the docstring at the top of that file). There is
still no real FK to a `brand_*` table (there isn't one shared table to
reference), so referential integrity is enforced at the application layer,
exactly like the store-intelligence tables.
CRITICAL DATA-INTEGRITY NOTE (Feature 15)
------------------------------------------
Every numeric column in `nutrition_facts` is nullable and stays NULL
unless a value was actually returned by a trusted source (currently Open
Food Facts - see `nutrition_data_service.py`). Nothing in this module
ever writes an estimated, interpolated, or LLM-guessed number into these
columns. `data_status` records whether a row has verified data
('verified'), no verified data was found ('unavailable'), or only some
fields were confirmed ('partial') - callers must check this before
treating NULL as "zero".
"""
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List, Optional
import pandas as pd
import psycopg
from psycopg.rows import dict_row
from psycopg.types.json import Json
from app.services.vector_store import _connect # reuse the exact same connection logic
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
SCHEMA_DDL = """
CREATE TABLE IF NOT EXISTS nutrition_facts (
brand TEXT NOT NULL,
image_id TEXT NOT NULL,
product_name TEXT,
category TEXT,
data_status TEXT NOT NULL DEFAULT 'unavailable', -- 'verified' | 'partial' | 'unavailable'
data_source TEXT, -- 'openfoodfacts' | 'manual'
source_ref TEXT, -- OFF product code / barcode
source_url TEXT,
match_confidence NUMERIC, -- 0-1, name-match confidence to the source record
serving_size_g NUMERIC,
serving_size_label TEXT,
-- Per-100g core fields (flat + indexed - these are the ones the
-- Feature 12 filter endpoints like "high protein" / "low sugar" sort on)
calories_kcal NUMERIC,
protein_g NUMERIC,
carbohydrates_g NUMERIC,
total_sugar_g NUMERIC,
added_sugar_g NUMERIC,
dietary_fiber_g NUMERIC,
total_fat_g NUMERIC,
saturated_fat_g NUMERIC,
trans_fat_g NUMERIC,
cholesterol_mg NUMERIC,
sodium_mg NUMERIC,
potassium_mg NUMERIC,
calcium_mg NUMERIC,
iron_mg NUMERIC,
magnesium_mg NUMERIC,
zinc_mg NUMERIC,
vitamin_a_mcg NUMERIC,
vitamin_c_mg NUMERIC,
vitamin_d_mcg NUMERIC,
vitamin_e_mg NUMERIC,
omega_3_g NUMERIC,
omega_6_g NUMERIC,
-- Everything else verified (individual B vitamins, iodine, etc.) plus
-- the same shape again for "per serving" display - kept as JSONB
-- rather than dozens more flat columns since the field set a source
-- actually reports varies a lot product to product.
extended_nutrients JSONB,
per_serving JSONB,
ingredients_text TEXT,
off_nutriscore TEXT, -- external Nutri-Score grade ('a'..'e') if the source provides one, shown as-is
fetched_at TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (brand, image_id)
);
CREATE INDEX IF NOT EXISTS idx_nutrition_facts_category ON nutrition_facts(category);
CREATE INDEX IF NOT EXISTS idx_nutrition_facts_protein ON nutrition_facts(protein_g DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_nutrition_facts_fiber ON nutrition_facts(dietary_fiber_g DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_nutrition_facts_sugar ON nutrition_facts(total_sugar_g ASC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_nutrition_facts_sodium ON nutrition_facts(sodium_mg ASC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_nutrition_facts_calcium ON nutrition_facts(calcium_mg DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_nutrition_facts_iron ON nutrition_facts(iron_mg DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_nutrition_facts_vitc ON nutrition_facts(vitamin_c_mg DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_nutrition_facts_status ON nutrition_facts(data_status);
CREATE TABLE IF NOT EXISTS nutrition_insights (
brand TEXT NOT NULL,
image_id TEXT NOT NULL,
nutrition_score NUMERIC, -- 0-100, nutrient-density composite
health_score NUMERIC, -- 0-100, nutrition_score adjusted for calorie density
score_breakdown JSONB, -- {protein_score, fiber_score, sugar_score, sodium_score, fat_quality_score, micronutrient_score, ...}
scoring_version TEXT,
positive_insights TEXT[],
nutritional_cautions TEXT[],
ai_summary TEXT,
diet_tags TEXT[],
allergens TEXT[],
allergen_source TEXT, -- 'openfoodfacts' | 'unavailable'
nutrition_cluster INTEGER,
nutrition_cluster_label TEXT,
data_status TEXT NOT NULL DEFAULT 'unavailable',
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (brand, image_id)
);
CREATE INDEX IF NOT EXISTS idx_nutrition_insights_health ON nutrition_insights(health_score DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_nutrition_insights_nutrition ON nutrition_insights(nutrition_score DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_nutrition_insights_diet_tags ON nutrition_insights USING GIN(diet_tags);
CREATE INDEX IF NOT EXISTS idx_nutrition_insights_allergens ON nutrition_insights USING GIN(allergens);
CREATE INDEX IF NOT EXISTS idx_nutrition_insights_cluster ON nutrition_insights(nutrition_cluster);
CREATE TABLE IF NOT EXISTS nutrition_similar_products (
id BIGSERIAL PRIMARY KEY,
brand TEXT NOT NULL,
image_id TEXT NOT NULL,
rank INTEGER NOT NULL,
similar_brand TEXT NOT NULL,
similar_image_id TEXT NOT NULL,
similarity_score NUMERIC NOT NULL,
method TEXT NOT NULL DEFAULT 'cosine',
computed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_nutrition_similar_source ON nutrition_similar_products(brand, image_id, rank);
CREATE TABLE IF NOT EXISTS nutrition_healthy_alternatives (
id BIGSERIAL PRIMARY KEY,
brand TEXT NOT NULL,
image_id TEXT NOT NULL,
rank INTEGER NOT NULL,
alt_brand TEXT NOT NULL,
alt_image_id TEXT NOT NULL,
health_score_delta NUMERIC,
reason TEXT,
computed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_nutrition_alt_source ON nutrition_healthy_alternatives(brand, image_id, rank);
"""
def ensure_nutrition_schema() -> bool:
"""Idempotent: safe to call on every API startup, mirrors
`ensure_store_intelligence_schema()`."""
conn = _connect()
if not conn:
logger.warning("Nutrition schema not created: no DB connection")
return False
try:
with conn, conn.cursor() as cur:
cur.execute(SCHEMA_DDL)
logger.info("Nutrition-intelligence schema ensured (nutrition_facts, nutrition_insights, "
"nutrition_similar_products, nutrition_healthy_alternatives)")
return True
except Exception as e: # noqa: BLE001
logger.error(f"Failed to ensure nutrition schema: {e}")
return False
finally:
conn.close()
def _dict_cursor(conn: psycopg.Connection):
return conn.cursor(row_factory=dict_row)
NUMERIC_FACT_COLUMNS = [
"calories_kcal", "protein_g", "carbohydrates_g", "total_sugar_g", "added_sugar_g",
"dietary_fiber_g", "total_fat_g", "saturated_fat_g", "trans_fat_g", "cholesterol_mg",
"sodium_mg", "potassium_mg", "calcium_mg", "iron_mg", "magnesium_mg", "zinc_mg",
"vitamin_a_mcg", "vitamin_c_mg", "vitamin_d_mcg", "vitamin_e_mg", "omega_3_g",
"omega_6_g", "serving_size_g", "match_confidence",
]
NUMERIC_INSIGHT_COLUMNS = ["nutrition_score", "health_score"]
def _cast_numeric(df: pd.DataFrame, columns: List[str]) -> pd.DataFrame:
"""Postgres NUMERIC -> decimal.Decimal on read; scikit-learn/numpy need floats."""
for col in columns:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
return df
def _row_numeric(row: Dict[str, Any], columns: List[str]) -> Dict[str, Any]:
for col in columns:
if row.get(col) is not None:
row[col] = float(row[col])
return row
# ---------------------------------------------------------------------------
# nutrition_facts
# ---------------------------------------------------------------------------
_FACT_COLUMNS = [
"brand", "image_id", "product_name", "category", "data_status", "data_source",
"source_ref", "source_url", "match_confidence", "serving_size_g", "serving_size_label",
"calories_kcal", "protein_g", "carbohydrates_g", "total_sugar_g", "added_sugar_g",
"dietary_fiber_g", "total_fat_g", "saturated_fat_g", "trans_fat_g", "cholesterol_mg",
"sodium_mg", "potassium_mg", "calcium_mg", "iron_mg", "magnesium_mg", "zinc_mg",
"vitamin_a_mcg", "vitamin_c_mg", "vitamin_d_mcg", "vitamin_e_mg", "omega_3_g", "omega_6_g",
"extended_nutrients", "per_serving", "ingredients_text", "off_nutriscore", "fetched_at",
]
def upsert_nutrition_facts(facts: Dict[str, Any]) -> bool:
"""`facts` must include brand + image_id; every other key in
`_FACT_COLUMNS` is optional and defaults to NULL (never fabricated)."""
conn = _connect()
if not conn:
return False
row = {col: facts.get(col) for col in _FACT_COLUMNS}
if not row["brand"] or not row["image_id"]:
return False
if isinstance(row.get("extended_nutrients"), (dict, list)):
row["extended_nutrients"] = Json(row["extended_nutrients"])
if isinstance(row.get("per_serving"), (dict, list)):
row["per_serving"] = Json(row["per_serving"])
cols = ", ".join(_FACT_COLUMNS)
placeholders = ", ".join(f"%({c})s" for c in _FACT_COLUMNS)
update_cols = [c for c in _FACT_COLUMNS if c not in ("brand", "image_id")]
update_clause = ", ".join(f"{c} = EXCLUDED.{c}" for c in update_cols)
try:
with conn, conn.cursor() as cur:
cur.execute(
f"""
INSERT INTO nutrition_facts ({cols})
VALUES ({placeholders})
ON CONFLICT (brand, image_id) DO UPDATE SET
{update_clause}, updated_at = CURRENT_TIMESTAMP
""",
row,
)
return True
except Exception as e: # noqa: BLE001
logger.error(f"upsert_nutrition_facts failed for {row['brand']}/{row['image_id']}: {e}")
return False
finally:
conn.close()
def get_nutrition_facts(brand: str, image_id: str) -> Optional[Dict[str, Any]]:
conn = _connect()
if not conn:
return None
try:
with conn, _dict_cursor(conn) as cur:
cur.execute("SELECT * FROM nutrition_facts WHERE brand = %s AND image_id = %s", (brand, image_id))
row = cur.fetchone()
return _row_numeric(dict(row), NUMERIC_FACT_COLUMNS) if row else None
except Exception as e: # noqa: BLE001
logger.error(f"get_nutrition_facts failed: {e}")
return None
finally:
conn.close()
def get_all_nutrition_facts_df() -> pd.DataFrame:
"""Every verified/partial nutrition_facts row, for the ML layer
(similarity + clustering) and analytics aggregation. Rows with no
verified data at all are excluded since there is nothing numeric to
compute over."""
conn = _connect()
if not conn:
return pd.DataFrame()
try:
with conn, _dict_cursor(conn) as cur:
cur.execute("SELECT * FROM nutrition_facts WHERE data_status != 'unavailable'")
rows = [dict(r) for r in cur.fetchall()]
df = pd.DataFrame(rows)
return _cast_numeric(df, NUMERIC_FACT_COLUMNS) if not df.empty else df
except Exception as e: # noqa: BLE001
logger.error(f"get_all_nutrition_facts_df failed: {e}")
return pd.DataFrame()
finally:
conn.close()
def enrichment_status_counts() -> Dict[str, int]:
conn = _connect()
if not conn:
return {}
try:
with conn, conn.cursor() as cur:
cur.execute("SELECT data_status, COUNT(*) FROM nutrition_facts GROUP BY data_status")
return {status: count for status, count in cur.fetchall()}
except Exception as e: # noqa: BLE001
logger.error(f"enrichment_status_counts failed: {e}")
return {}
finally:
conn.close()
# ---------------------------------------------------------------------------
# nutrition_insights
# ---------------------------------------------------------------------------
_INSIGHT_COLUMNS = [
"brand", "image_id", "nutrition_score", "health_score", "score_breakdown",
"scoring_version", "positive_insights", "nutritional_cautions", "ai_summary",
"diet_tags", "allergens", "allergen_source", "nutrition_cluster",
"nutrition_cluster_label", "data_status",
]
def upsert_nutrition_insights(insights: Dict[str, Any]) -> bool:
conn = _connect()
if not conn:
return False
row = {col: insights.get(col) for col in _INSIGHT_COLUMNS}
if not row["brand"] or not row["image_id"]:
return False
if isinstance(row.get("score_breakdown"), dict):
row["score_breakdown"] = Json(row["score_breakdown"])
for arr_col in ("positive_insights", "nutritional_cautions", "diet_tags", "allergens"):
if row.get(arr_col) is None:
row[arr_col] = []
cols = ", ".join(_INSIGHT_COLUMNS)
placeholders = ", ".join(f"%({c})s" for c in _INSIGHT_COLUMNS)
update_cols = [c for c in _INSIGHT_COLUMNS if c not in ("brand", "image_id")]
update_clause = ", ".join(f"{c} = EXCLUDED.{c}" for c in update_cols)
try:
with conn, conn.cursor() as cur:
cur.execute(
f"""
INSERT INTO nutrition_insights ({cols})
VALUES ({placeholders})
ON CONFLICT (brand, image_id) DO UPDATE SET
{update_clause}, generated_at = CURRENT_TIMESTAMP
""",
row,
)
return True
except Exception as e: # noqa: BLE001
logger.error(f"upsert_nutrition_insights failed for {row['brand']}/{row['image_id']}: {e}")
return False
finally:
conn.close()
def get_nutrition_insights(brand: str, image_id: str) -> Optional[Dict[str, Any]]:
conn = _connect()
if not conn:
return None
try:
with conn, _dict_cursor(conn) as cur:
cur.execute("SELECT * FROM nutrition_insights WHERE brand = %s AND image_id = %s", (brand, image_id))
row = cur.fetchone()
return _row_numeric(dict(row), NUMERIC_INSIGHT_COLUMNS) if row else None
except Exception as e: # noqa: BLE001
logger.error(f"get_nutrition_insights failed: {e}")
return None
finally:
conn.close()
def get_full_nutrition(brand: str, image_id: str) -> Optional[Dict[str, Any]]:
"""Merged facts + insights for a single product (the shape the main
product-detail nutrition endpoint returns). Returns a "data
unavailable" shell (never None) if the product has been catalog-ingested
but nutrition enrichment hasn't run for it yet, so the frontend always
has something to render."""
facts = get_nutrition_facts(brand, image_id) or {
"brand": brand, "image_id": image_id, "data_status": "unavailable",
}
insights = get_nutrition_insights(brand, image_id) or {
"brand": brand, "image_id": image_id, "data_status": "unavailable",
"positive_insights": [], "nutritional_cautions": [], "diet_tags": [], "allergens": [],
}
merged = {**facts, **{k: v for k, v in insights.items() if k not in ("brand", "image_id")}}
return merged
# ---------------------------------------------------------------------------
# Filter / sort queries (Feature 12: high-protein, low-sugar, high-fiber, diet-compatible)
# ---------------------------------------------------------------------------
_SORTABLE_COLUMNS = {
"protein": "protein_g", "fiber": "dietary_fiber_g", "sugar": "total_sugar_g",
"sodium": "sodium_mg", "calcium": "calcium_mg", "iron": "iron_mg",
"vitamin_c": "vitamin_c_mg", "calories": "calories_kcal",
"health_score": "health_score", "nutrition_score": "nutrition_score",
}
def query_products(
sort_by: str = "health_score",
order: str = "desc",
category: Optional[str] = None,
diet_tag: Optional[str] = None,
exclude_allergen: Optional[str] = None,
limit: int = 20,
offset: int = 0,
) -> List[Dict[str, Any]]:
"""Core listing query behind every Feature 12 filter/sort endpoint
(high-protein, low-sugar, high-fiber products, diet-compatible
products, ...). Joins nutrition_facts + nutrition_insights on the
shared (brand, image_id) key."""
conn = _connect()
if not conn:
return []
col = _SORTABLE_COLUMNS.get(sort_by, "health_score")
table = "nutrition_insights i" if col in ("health_score", "nutrition_score") else "nutrition_facts f"
direction = "ASC" if order == "asc" else "DESC"
where = ["f.data_status != 'unavailable'"]
params: List[Any] = []
if category:
where.append("f.category ILIKE %s")
params.append(f"%{category}%")
if diet_tag:
where.append("i.diet_tags @> %s::text[]")
params.append([diet_tag])
if exclude_allergen:
where.append("NOT (i.allergens @> %s::text[])")
params.append([exclude_allergen])
where_clause = " AND ".join(where)
sql = f"""
SELECT f.brand, f.image_id, f.product_name, f.category,
f.calories_kcal, f.protein_g, f.dietary_fiber_g, f.total_sugar_g,
f.sodium_mg, f.calcium_mg, f.iron_mg, f.vitamin_c_mg,
i.nutrition_score, i.health_score, i.diet_tags, i.allergens
FROM nutrition_facts f
LEFT JOIN nutrition_insights i ON i.brand = f.brand AND i.image_id = f.image_id
WHERE {where_clause} AND {col} IS NOT NULL
ORDER BY {col} {direction} NULLS LAST
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
try:
with conn, _dict_cursor(conn) as cur:
cur.execute(sql, params)
rows = [dict(r) for r in cur.fetchall()]
return [_row_numeric(r, NUMERIC_FACT_COLUMNS + NUMERIC_INSIGHT_COLUMNS) for r in rows]
except Exception as e: # noqa: BLE001
logger.error(f"query_products failed: {e}")
return []
finally:
conn.close()
# ---------------------------------------------------------------------------
# Similarity / alternatives caches
# ---------------------------------------------------------------------------
def save_similar_products(brand: str, image_id: str, ranked: List[Dict[str, Any]], method: str = "cosine") -> None:
conn = _connect()
if not conn:
return
try:
with conn, conn.cursor() as cur:
cur.execute("DELETE FROM nutrition_similar_products WHERE brand = %s AND image_id = %s", (brand, image_id))
for rank, item in enumerate(ranked, start=1):
cur.execute(
"""
INSERT INTO nutrition_similar_products
(brand, image_id, rank, similar_brand, similar_image_id, similarity_score, method)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(brand, image_id, rank, item["brand"], item["image_id"], item["similarity_score"], method),
)
except Exception as e: # noqa: BLE001
logger.error(f"save_similar_products failed: {e}")
finally:
conn.close()
def get_similar_products(brand: str, image_id: str, top_k: int = 5) -> List[Dict[str, Any]]:
conn = _connect()
if not conn:
return []
try:
with conn, _dict_cursor(conn) as cur:
cur.execute(
"""
SELECT similar_brand AS brand, similar_image_id AS image_id, similarity_score, method
FROM nutrition_similar_products
WHERE brand = %s AND image_id = %s
ORDER BY rank ASC LIMIT %s
""",
(brand, image_id, top_k),
)
return [_row_numeric(dict(r), ["similarity_score"]) for r in cur.fetchall()]
except Exception as e: # noqa: BLE001
logger.error(f"get_similar_products failed: {e}")
return []
finally:
conn.close()
def save_healthy_alternatives(brand: str, image_id: str, ranked: List[Dict[str, Any]]) -> None:
conn = _connect()
if not conn:
return
try:
with conn, conn.cursor() as cur:
cur.execute("DELETE FROM nutrition_healthy_alternatives WHERE brand = %s AND image_id = %s", (brand, image_id))
for rank, item in enumerate(ranked, start=1):
cur.execute(
"""
INSERT INTO nutrition_healthy_alternatives
(brand, image_id, rank, alt_brand, alt_image_id, health_score_delta, reason)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(brand, image_id, rank, item["brand"], item["image_id"],
item.get("health_score_delta"), item.get("reason")),
)
except Exception as e: # noqa: BLE001
logger.error(f"save_healthy_alternatives failed: {e}")
finally:
conn.close()
def get_healthy_alternatives(brand: str, image_id: str, top_k: int = 5) -> List[Dict[str, Any]]:
conn = _connect()
if not conn:
return []
try:
with conn, _dict_cursor(conn) as cur:
cur.execute(
"""
SELECT alt_brand AS brand, alt_image_id AS image_id, health_score_delta, reason
FROM nutrition_healthy_alternatives
WHERE brand = %s AND image_id = %s
ORDER BY rank ASC LIMIT %s
""",
(brand, image_id, top_k),
)
return [_row_numeric(dict(r), ["health_score_delta"]) for r in cur.fetchall()]
except Exception as e: # noqa: BLE001
logger.error(f"get_healthy_alternatives failed: {e}")
return []
finally:
conn.close()
# ---------------------------------------------------------------------------
# Analytics (Feature 9)
# ---------------------------------------------------------------------------
def top_by_nutrient(nutrient: str, limit: int = 10, ascending: bool = False) -> List[Dict[str, Any]]:
col = _SORTABLE_COLUMNS.get(nutrient)
if not col:
return []
return query_products(sort_by=nutrient, order="asc" if ascending else "desc", limit=limit)
def healthiest_brands(limit: int = 10) -> List[Dict[str, Any]]:
conn = _connect()
if not conn:
return []
try:
with conn, _dict_cursor(conn) as cur:
cur.execute(
"""
SELECT f.brand, ROUND(AVG(i.health_score)::numeric, 1) AS avg_health_score,
ROUND(AVG(i.nutrition_score)::numeric, 1) AS avg_nutrition_score,
COUNT(*) AS product_count
FROM nutrition_facts f
JOIN nutrition_insights i ON i.brand = f.brand AND i.image_id = f.image_id
WHERE f.data_status != 'unavailable' AND i.health_score IS NOT NULL
GROUP BY f.brand
HAVING COUNT(*) >= 1
ORDER BY avg_health_score DESC
LIMIT %s
""",
(limit,),
)
return [_row_numeric(dict(r), ["avg_health_score", "avg_nutrition_score"]) for r in cur.fetchall()]
except Exception as e: # noqa: BLE001
logger.error(f"healthiest_brands failed: {e}")
return []
finally:
conn.close()
def healthiest_categories(limit: int = 10) -> List[Dict[str, Any]]:
conn = _connect()
if not conn:
return []
try:
with conn, _dict_cursor(conn) as cur:
cur.execute(
"""
SELECT f.category, ROUND(AVG(i.health_score)::numeric, 1) AS avg_health_score,
ROUND(AVG(i.nutrition_score)::numeric, 1) AS avg_nutrition_score,
COUNT(*) AS product_count
FROM nutrition_facts f
JOIN nutrition_insights i ON i.brand = f.brand AND i.image_id = f.image_id
WHERE f.data_status != 'unavailable' AND i.health_score IS NOT NULL AND f.category IS NOT NULL
GROUP BY f.category
ORDER BY avg_health_score DESC
LIMIT %s
""",
(limit,),
)
return [_row_numeric(dict(r), ["avg_health_score", "avg_nutrition_score"]) for r in cur.fetchall()]
except Exception as e: # noqa: BLE001
logger.error(f"healthiest_categories failed: {e}")
return []
finally:
conn.close()
def score_distribution() -> Dict[str, int]:
"""Bucketed health_score histogram (0-20, 20-40, ..., 80-100) - used
for the analytics dashboard's distribution chart."""
conn = _connect()
if not conn:
return {}
try:
with conn, conn.cursor() as cur:
cur.execute(
"""
SELECT WIDTH_BUCKET(health_score, 0, 100, 5) AS bucket, COUNT(*)
FROM nutrition_insights
WHERE health_score IS NOT NULL
GROUP BY bucket ORDER BY bucket
"""
)
labels = {1: "0-20", 2: "20-40", 3: "40-60", 4: "60-80", 5: "80-100"}
return {labels.get(b, str(b)): c for b, c in cur.fetchall()}
except Exception as e: # noqa: BLE001
logger.error(f"score_distribution failed: {e}")
return {}
finally:
conn.close()
def store_healthy_distribution() -> List[Dict[str, Any]]:
"""Store-wise healthy-product distribution - joins onto `store_inventory`
from the v3.0 Store Intelligence layer (Feature 9's last bullet). Returns
[] gracefully if that layer hasn't been seeded, since it's an optional
dependency of this module, not a requirement."""
conn = _connect()
if not conn:
return []
try:
with conn, _dict_cursor(conn) as cur:
cur.execute(
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'store_inventory')"
)
if not cur.fetchone()["exists"]:
return []
cur.execute(
"""
SELECT si.store_id,
COUNT(*) FILTER (WHERE i.health_score >= 60) AS healthy_products,
COUNT(*) FILTER (WHERE i.health_score IS NOT NULL) AS scored_products
FROM store_inventory si
JOIN nutrition_insights i ON i.brand = si.brand AND i.image_id = si.image_id
GROUP BY si.store_id ORDER BY si.store_id
"""
)
return [dict(r) for r in cur.fetchall()]
except Exception as e: # noqa: BLE001
logger.error(f"store_healthy_distribution failed: {e}")
return []
finally:
conn.close()

View File

@@ -0,0 +1,164 @@
"""
Orchestrates the RAG-style pipeline the spec asks for end-to-end:
1. RETRIEVE verified facts -> nutrition_data_service.fetch_verified_nutrition
2. SCORE + CLASSIFY (rules) -> nutrition_scoring.*
3. NARRATE (LLM, grounded) -> nutrition_narrative_service.generate_summary
4. PERSIST -> nutrition_db.upsert_*
Steps 1-2 never touch an LLM. Step 3 only ever sees the already-computed
bullet lists from step 2, never the raw retrieval payload - so even if
something upstream changes, the LLM physically cannot introduce a
nutrient value that didn't come from step 1.
This file is imported by both `app/api/routers/nutrition_admin.py`
(background job for the web UI) and `scripts/enrich_nutrition.py` (CLI),
so the two stay in sync by construction rather than by convention.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from app.services import nutrition_data_service, nutrition_db, nutrition_scoring
from app.services.nutrition_narrative_service import generate_summary
from app.services.vector_store import get_products_by_brand, list_available_brands
logger = logging.getLogger(__name__)
@dataclass
class EnrichmentResult:
total_products: int = 0
verified: int = 0
partial: int = 0
unavailable: int = 0
errors: List[str] = field(default_factory=list)
duration_seconds: float = 0.0
def enrich_one_product(brand: str, image_id: str, product_name: str, category: str,
skip_if_verified: bool = False, generate_narrative: bool = True) -> str:
"""Runs the full pipeline for a single product. Returns the resulting
`data_status` ('verified' | 'partial' | 'unavailable')."""
if skip_if_verified:
existing = nutrition_db.get_nutrition_facts(brand, image_id)
if existing and existing.get("data_status") == "verified":
return "verified"
facts = nutrition_data_service.fetch_verified_nutrition(brand, product_name, category or "")
facts["brand"] = brand
facts["image_id"] = image_id
facts["product_name"] = product_name
facts["category"] = category
nutrition_db.upsert_nutrition_facts(facts)
scores = nutrition_scoring.compute_scores(facts)
positive = nutrition_scoring.generate_positive_insights(facts)
cautions = nutrition_scoring.generate_cautions(facts)
diet_tags = nutrition_scoring.classify_diet_tags(facts)
allergens = nutrition_scoring.normalize_allergens(facts)
ai_summary = ""
if generate_narrative and (positive or cautions):
try:
ai_summary = generate_summary(product_name, positive, cautions)
except Exception as e: # noqa: BLE001
logger.warning(f"Narrative generation failed for {brand}/{image_id}: {e}")
insights: Dict[str, Any] = {
"brand": brand, "image_id": image_id,
"positive_insights": positive, "nutritional_cautions": cautions,
"ai_summary": ai_summary, "diet_tags": diet_tags, "allergens": allergens,
"allergen_source": "openfoodfacts" if allergens else "unavailable",
"data_status": facts["data_status"],
}
if scores:
insights.update(scores)
nutrition_db.upsert_nutrition_insights(insights)
return facts["data_status"]
def enrich_all_products(
skip_if_verified: bool = True,
generate_narrative: bool = True,
progress_cb: Optional[Callable[[int, int], None]] = None,
max_products: Optional[int] = None,
) -> EnrichmentResult:
"""Iterates every product across every brand table (via
`vector_store.list_available_brands` / `get_products_by_brand` -
the exact same source of truth the catalog UI reads from) and runs
the pipeline on each."""
start = time.time()
result = EnrichmentResult()
brands = list_available_brands()
all_products: List[Dict[str, Any]] = []
for brand in brands:
for p in get_products_by_brand(brand):
all_products.append({
"brand": brand, "image_id": p.get("image_id"),
"product_name": p.get("title") or p.get("product_name"),
"category": p.get("category"),
})
if max_products:
all_products = all_products[:max_products]
result.total_products = len(all_products)
for i, p in enumerate(all_products):
if not p["image_id"] or not p["product_name"]:
result.errors.append(f"skipped {p['brand']}: missing image_id/product_name")
continue
try:
status = enrich_one_product(
p["brand"], p["image_id"], p["product_name"], p["category"],
skip_if_verified=skip_if_verified, generate_narrative=generate_narrative,
)
if status == "verified":
result.verified += 1
elif status == "partial":
result.partial += 1
else:
result.unavailable += 1
except Exception as e: # noqa: BLE001
logger.error(f"Enrichment failed for {p['brand']}/{p['image_id']}: {e}")
result.errors.append(f"{p['brand']}/{p['image_id']}: {e}")
if progress_cb:
progress_cb(i + 1, result.total_products)
result.duration_seconds = round(time.time() - start, 1)
logger.info(
f"Nutrition enrichment complete: {result.verified} verified, {result.partial} partial, "
f"{result.unavailable} unavailable of {result.total_products} products in {result.duration_seconds}s"
)
return result
def train_all_models() -> Dict[str, Any]:
"""Trains the similarity + clustering models over whatever is
currently verified/partial in `nutrition_facts`, and refreshes the
cached similar-products table for every enriched product."""
from app.intelligence import nutrition_clustering, nutrition_similarity
df = nutrition_db.get_all_nutrition_facts_df()
similarity_result = nutrition_similarity.train_similarity_index(df)
clustering_result = nutrition_clustering.train_clusters(df)
# Refresh cluster labels on nutrition_insights + the similar-products cache.
assignments = nutrition_clustering.get_assignments()
for (brand, image_id), info in assignments.items():
existing = nutrition_db.get_nutrition_insights(brand, image_id) or {"brand": brand, "image_id": image_id}
existing["nutrition_cluster"] = info["cluster"]
existing["nutrition_cluster_label"] = info["label"]
nutrition_db.upsert_nutrition_insights(existing)
if similarity_result.get("trained"):
for brand, image_id in df[["brand", "image_id"]].itertuples(index=False):
similar = nutrition_similarity.find_similar(brand, image_id, top_k=5)
if similar:
nutrition_db.save_similar_products(brand, image_id, similar)
return {"similarity": similarity_result, "clustering": clustering_result}

View File

@@ -0,0 +1,52 @@
"""
Feature 2: "AI Nutritional Insights" narrative generation.
By the time anything reaches this file, every fact has already been
verified (`nutrition_data_service.py`) and every insight/caution has
already been computed by pure rules over those verified numbers
(`nutrition_scoring.py`). The LLM's ONLY job here is to turn an existing
bullet list into a smoother paragraph - it is explicitly instructed not
to add a single new nutrient, number, or claim, and if Ollama is
unreachable this degrades to a plain join of the bullets (still fully
factual, just less polished prose) rather than failing silently.
"""
from __future__ import annotations
from typing import List
from app.services.ollama_service import generate_grounded_text
_SYSTEM_PROMPT = (
"You are a nutrition-label copywriter. You will be given a list of FACTS "
"(already verified, already computed - not yours to question or extend) "
"about one packaged food product. Rewrite them as a short, natural, "
"factual paragraph (2-4 sentences).\n"
"Rules:\n"
"1. Use ONLY the facts given. Do not add any nutrient, number, ingredient, "
"or claim that isn't in the list.\n"
"2. Do not make medical, disease-related, or health-outcome claims "
"(e.g. never say something 'prevents', 'cures', 'is bad for', or "
"'reduces risk of' any condition). Describe composition only.\n"
"3. Do not use superlatives not supported by the facts (no 'amazing', "
"'perfect', 'best').\n"
"4. Keep it plain and factual, like a nutrition label summary, not "
"marketing copy.\n"
"5. Return ONLY the paragraph, no preamble, no bullet points, no headers."
)
def generate_summary(product_name: str, positive_insights: List[str], cautions: List[str]) -> str:
if not positive_insights and not cautions:
return "Nutrition data for this product is limited, so a detailed summary isn't available yet."
facts_block = "POSITIVE POINTS:\n" + "\n".join(f"- {p}" for p in positive_insights) if positive_insights else "POSITIVE POINTS: none verified"
cautions_block = "\nCAUTIONS:\n" + "\n".join(f"- {c}" for c in cautions) if cautions else "\nCAUTIONS: none"
user_prompt = f"Product: {product_name}\n\n{facts_block}{cautions_block}\n\nWrite the paragraph now."
text = generate_grounded_text(_SYSTEM_PROMPT, user_prompt).strip()
if text:
return text
# Non-LLM fallback: still fully factual, just a plain join.
parts = list(positive_insights) + list(cautions)
return " ".join(parts) if parts else "Nutrition data for this product is limited, so a detailed summary isn't available yet."

View File

@@ -0,0 +1,347 @@
"""
Features 2, 3, 5, 6: nutrition/health scoring, diet-compatibility
classification, allergen normalization, and factual insights/cautions.
Everything in this file is a pure function over already-verified numbers
from `nutrition_data_service.py` - no network calls, no LLM calls, no
randomness. That's a deliberate reading of two requirements together:
Feature 6 says the scoring algorithm "should be transparent and
configurable" - a plain weighted formula over published thresholds
satisfies that; a trained model would not (you can't hand someone a
model's weights and call it "transparent").
Feature 14 says "Do NOT use Machine Learning to generate nutritional
facts" and Feature 15 says cautions must not be "misleading or
unsupported medical claims". Keeping cautions/insights as rule-based
statements directly over the verified numbers (e.g. "Contains high
added sugar" when added_sugar_g > 22.5) means every sentence traces to
a specific verified value and a specific published threshold - there
is nothing here for an LLM to hallucinate, because the LLM never
touches the numbers (see `nutrition_narrative_service.py`, which only
rephrases the bullet lists this module already produced).
THRESHOLDS
----------
Sugar/fat/saturated-fat/salt "high"/"low" cut-offs below are the UK Food
Standards Agency's published front-of-pack traffic-light thresholds
(per 100g solid food) - a long-standing, publicly documented labeling
convention, not a value we invented. Fiber/protein "source"/"high"
thresholds are the EU nutrition-claim thresholds (Regulation
1924/2006). Micronutrient "good source" thresholds use 15% of a
published Reference Daily Intake (RDI) per 100g, also a standard
labeling convention. All of it is configurable in `THRESHOLDS` /
`SCORE_WEIGHTS` below rather than hard-coded inline, per the "should be
... configurable" requirement.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
SCORING_VERSION = "v1.0-fsa-eu-thresholds"
# ---------------------------------------------------------------------------
# Configurable thresholds (per 100g unless noted)
# ---------------------------------------------------------------------------
THRESHOLDS = {
"sugar_high": 22.5, "sugar_low": 5.0, # g
"sat_fat_high": 5.0, "sat_fat_low": 1.5, # g
"fat_high": 17.5, "fat_low": 3.0, # g
"sodium_high": 600.0, "sodium_low": 120.0, # mg
"fiber_source": 3.0, "fiber_high": 6.0, # g
"protein_source_g": 5.0, "protein_high_g": 10.0, # g (fallback when calorie ratio unavailable)
"protein_high_energy_ratio": 0.20, # protein kcal / total kcal
"calorie_dense": 400.0, "calorie_light": 120.0, # kcal
"keto_carb_ceiling": 10.0, # g net carbs / 100g
"low_fat_ceiling": 3.0, # g
}
# RDI values used ONLY to phrase "good source of X" (>=15% RDI/100g) -
# published nutrition-labeling reference values, not medical claims.
RDI = {
"calcium_mg": 800.0, "iron_mg": 14.0, "vitamin_c_mg": 80.0,
"vitamin_d_mcg": 5.0, "vitamin_a_mcg": 800.0, "potassium_mg": 2000.0,
"zinc_mg": 10.0, "magnesium_mg": 375.0,
}
GOOD_SOURCE_RDI_PCT = 0.15
SCORE_WEIGHTS = {
"protein": 0.20, "fiber": 0.20, "sugar": 0.25,
"sodium": 0.15, "fat_quality": 0.15, "micronutrient": 0.05,
}
WHOLE_GRAIN_BONUS = 5.0
CALORIE_DENSITY_PENALTY_MAX = 10.0
def _clip(x: float, lo: float = 0.0, hi: float = 100.0) -> float:
return max(lo, min(hi, x))
def _linear_score(value: float, good_at: float, bad_at: float) -> float:
"""100 at/beyond `good_at`, 0 at/beyond `bad_at`, linear between.
Works whether `good_at` is above or below `bad_at` (i.e. handles
both 'more is better' and 'less is better' nutrients)."""
if good_at == bad_at:
return 100.0
frac = (value - bad_at) / (good_at - bad_at)
return _clip(frac * 100)
# ---------------------------------------------------------------------------
# Scoring
# ---------------------------------------------------------------------------
def compute_scores(facts: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Returns None if there isn't enough verified data to score fairly
(never fabricates a score from missing numbers). Otherwise returns
`{nutrition_score, health_score, score_breakdown}`."""
if facts.get("data_status") in (None, "unavailable"):
return None
protein = facts.get("protein_g")
fiber = facts.get("dietary_fiber_g")
sugar = facts.get("total_sugar_g")
sodium = facts.get("sodium_mg")
sat_fat = facts.get("saturated_fat_g")
total_fat = facts.get("total_fat_g")
trans_fat = facts.get("trans_fat_g")
calories = facts.get("calories_kcal")
components: Dict[str, float] = {}
weights_used: Dict[str, float] = {}
if protein is not None:
if calories and calories > 0:
protein_ratio = (protein * 4) / calories
components["protein_score"] = _linear_score(protein_ratio, THRESHOLDS["protein_high_energy_ratio"], 0.0)
else:
components["protein_score"] = _linear_score(protein, THRESHOLDS["protein_high_g"], 0.0)
weights_used["protein"] = SCORE_WEIGHTS["protein"]
if fiber is not None:
components["fiber_score"] = _linear_score(fiber, THRESHOLDS["fiber_high"], 0.0)
weights_used["fiber"] = SCORE_WEIGHTS["fiber"]
if sugar is not None:
components["sugar_score"] = _linear_score(sugar, THRESHOLDS["sugar_low"], THRESHOLDS["sugar_high"])
weights_used["sugar"] = SCORE_WEIGHTS["sugar"]
if sodium is not None:
components["sodium_score"] = _linear_score(sodium, THRESHOLDS["sodium_low"], THRESHOLDS["sodium_high"])
weights_used["sodium"] = SCORE_WEIGHTS["sodium"]
if sat_fat is not None and total_fat is not None and total_fat > 0:
sat_ratio_score = _linear_score(sat_fat, THRESHOLDS["sat_fat_low"], THRESHOLDS["sat_fat_high"])
trans_penalty = 15.0 if (trans_fat or 0) > 0.5 else 0.0
components["fat_quality_score"] = _clip(sat_ratio_score - trans_penalty)
weights_used["fat_quality"] = SCORE_WEIGHTS["fat_quality"]
elif sat_fat is not None:
components["fat_quality_score"] = _linear_score(sat_fat, THRESHOLDS["sat_fat_low"], THRESHOLDS["sat_fat_high"])
weights_used["fat_quality"] = SCORE_WEIGHTS["fat_quality"]
micro_pcts = []
for col, rdi in RDI.items():
v = facts.get(col)
if v is not None and rdi:
micro_pcts.append(_clip((v / rdi) * 100))
if micro_pcts:
components["micronutrient_score"] = sum(micro_pcts) / len(micro_pcts)
weights_used["micronutrient"] = SCORE_WEIGHTS["micronutrient"]
if not components:
return None # no scoreable verified fields at all
total_weight = sum(weights_used.values())
nutrition_score = sum(components[f"{k}_score"] * w for k, w in weights_used.items()) / total_weight
ingredients = (facts.get("ingredients_text") or "").lower()
whole_grain_bonus = 0.0
if ingredients and ("whole grain" in ingredients or "whole wheat" in ingredients or "wholemeal" in ingredients):
# Only awarded when the ingredient is verified to appear FIRST
# (i.e. the primary ingredient), the standard "whole grain
# product" convention - not just present in trace amount.
first_ingredient = ingredients.split(",")[0]
if "whole" in first_ingredient:
whole_grain_bonus = WHOLE_GRAIN_BONUS
nutrition_score = _clip(nutrition_score + whole_grain_bonus)
calorie_penalty = 0.0
if calories is not None and calories > THRESHOLDS["calorie_dense"]:
over = min(calories - THRESHOLDS["calorie_dense"], THRESHOLDS["calorie_dense"])
calorie_penalty = (over / THRESHOLDS["calorie_dense"]) * CALORIE_DENSITY_PENALTY_MAX
health_score = _clip(nutrition_score - calorie_penalty)
breakdown = {k: round(v, 1) for k, v in components.items()}
breakdown["whole_grain_bonus"] = whole_grain_bonus
breakdown["calorie_density_penalty"] = round(calorie_penalty, 1)
breakdown["fields_used"] = sorted(weights_used.keys())
breakdown["fields_missing"] = sorted(set(SCORE_WEIGHTS.keys()) - set(weights_used.keys()))
return {
"nutrition_score": round(nutrition_score, 1),
"health_score": round(health_score, 1),
"score_breakdown": breakdown,
"scoring_version": SCORING_VERSION,
}
# ---------------------------------------------------------------------------
# Diet compatibility (Feature 5)
# ---------------------------------------------------------------------------
def classify_diet_tags(facts: Dict[str, Any]) -> List[str]:
"""Blends Open Food Facts' own ingredient-derived classification
(vegan/vegetarian - computed by OFF from the ingredient list, a
verified/algorithmic source, not our guess) with rule-based tags
computed directly from verified nutrient numbers."""
if facts.get("data_status") in (None, "unavailable"):
return []
tags: List[str] = []
labels = set(facts.get("off_labels_tags") or [])
analysis = set(facts.get("off_ingredients_analysis_tags") or [])
if "en:vegan" in labels or "en:vegan-status-vegan" in analysis:
tags.append("Vegan")
if "en:vegetarian" in labels or "en:vegetarian-status-vegetarian" in analysis or "Vegan" in tags:
tags.append("Vegetarian")
if "en:gluten-free" in labels:
tags.append("Gluten Free")
if "en:dairy-free" in labels or "Vegan" in tags:
tags.append("Dairy Free")
if "en:organic" in labels:
tags.append("Organic")
protein = facts.get("protein_g")
calories = facts.get("calories_kcal")
if protein is not None:
if calories and calories > 0 and (protein * 4) / calories >= THRESHOLDS["protein_high_energy_ratio"]:
tags.append("High Protein")
elif protein >= THRESHOLDS["protein_high_g"]:
tags.append("High Protein")
fiber = facts.get("dietary_fiber_g")
if fiber is not None and fiber >= THRESHOLDS["fiber_high"]:
tags.append("High Fiber")
total_fat = facts.get("total_fat_g")
if total_fat is not None and total_fat <= THRESHOLDS["low_fat_ceiling"]:
tags.append("Low Fat")
sugar = facts.get("total_sugar_g")
if sugar is not None and sugar <= THRESHOLDS["sugar_low"]:
tags.append("Low Sugar")
sodium = facts.get("sodium_mg")
if sodium is not None and sodium <= THRESHOLDS["sodium_low"]:
tags.append("Low Sodium")
carbs = facts.get("carbohydrates_g")
if carbs is not None and fiber is not None:
net_carbs = max(carbs - fiber, 0)
if net_carbs <= THRESHOLDS["keto_carb_ceiling"] and (total_fat or 0) > 0:
tags.append("Keto Friendly")
ingredients = (facts.get("ingredients_text") or "").lower()
if ingredients and ingredients.split(",")[0].find("whole") != -1:
tags.append("Whole Grain")
return sorted(set(tags))
def normalize_allergens(facts: Dict[str, Any]) -> List[str]:
seen = {}
for a in facts.get("allergens") or []:
key = a.strip().lower()
if key and key not in seen:
seen[key] = a.strip().title()
return sorted(seen.values())
# ---------------------------------------------------------------------------
# Factual insights / cautions (Feature 2 & 3) - rule-based only
# ---------------------------------------------------------------------------
def generate_positive_insights(facts: Dict[str, Any]) -> List[str]:
if facts.get("data_status") in (None, "unavailable"):
return []
out: List[str] = []
protein = facts.get("protein_g")
if protein is not None and protein >= THRESHOLDS["protein_source_g"]:
out.append(f"Good source of protein ({protein:.1f} g per 100 g).")
fiber = facts.get("dietary_fiber_g")
if fiber is not None:
if fiber >= THRESHOLDS["fiber_high"]:
out.append(f"High in dietary fiber ({fiber:.1f} g per 100 g), which may support digestive health.")
elif fiber >= THRESHOLDS["fiber_source"]:
out.append(f"Contains a source of dietary fiber ({fiber:.1f} g per 100 g).")
sat_fat = facts.get("saturated_fat_g")
if sat_fat is not None and sat_fat <= THRESHOLDS["sat_fat_low"]:
out.append(f"Low in saturated fat ({sat_fat:.1f} g per 100 g).")
sugar = facts.get("total_sugar_g")
if sugar is not None and sugar <= THRESHOLDS["sugar_low"]:
out.append(f"Low in sugar ({sugar:.1f} g per 100 g).")
sodium = facts.get("sodium_mg")
if sodium is not None and sodium <= THRESHOLDS["sodium_low"]:
out.append(f"Low in sodium ({sodium:.0f} mg per 100 g).")
micronutrient_labels = {
"calcium_mg": "calcium", "iron_mg": "iron", "vitamin_c_mg": "vitamin C",
"vitamin_d_mcg": "vitamin D", "vitamin_a_mcg": "vitamin A",
"potassium_mg": "potassium", "zinc_mg": "zinc", "magnesium_mg": "magnesium",
}
for col, label in micronutrient_labels.items():
v = facts.get(col)
rdi = RDI.get(col)
if v is not None and rdi and v >= rdi * GOOD_SOURCE_RDI_PCT:
out.append(f"Contains {label}, a source of at least {int(GOOD_SOURCE_RDI_PCT * 100)}% of the reference daily intake per 100 g.")
return out
def generate_cautions(facts: Dict[str, Any]) -> List[str]:
if facts.get("data_status") in (None, "unavailable"):
return []
out: List[str] = []
added_sugar = facts.get("added_sugar_g")
total_sugar = facts.get("total_sugar_g")
sugar_for_caution = added_sugar if added_sugar is not None else total_sugar
sugar_label = "added sugar" if added_sugar is not None else "sugar"
if sugar_for_caution is not None and sugar_for_caution > THRESHOLDS["sugar_high"]:
out.append(f"Contains high {sugar_label} ({sugar_for_caution:.1f} g per 100 g).")
sodium = facts.get("sodium_mg")
if sodium is not None and sodium > THRESHOLDS["sodium_high"]:
out.append(f"Contains high sodium ({sodium:.0f} mg per 100 g).")
sat_fat = facts.get("saturated_fat_g")
if sat_fat is not None and sat_fat > THRESHOLDS["sat_fat_high"]:
out.append(f"High in saturated fat ({sat_fat:.1f} g per 100 g).")
trans_fat = facts.get("trans_fat_g")
if trans_fat is not None and trans_fat > 0:
out.append(f"Contains trans fat ({trans_fat:.2f} g per 100 g).")
fiber = facts.get("dietary_fiber_g")
if fiber is not None and fiber < 1.0:
out.append("Low in dietary fiber.")
calories = facts.get("calories_kcal")
if calories is not None and calories > THRESHOLDS["calorie_dense"]:
out.append(f"High calorie density ({calories:.0f} kcal per 100 g).")
ingredients = (facts.get("ingredients_text") or "").lower()
if ingredients:
if any(term in ingredients for term in ("preservative", "sodium benzoate", "potassium sorbate", "sulphite", "sulfite")):
out.append("Contains preservatives (per ingredient list).")
if any(term in ingredients for term in ("colour", "color", "tartrazine", "sunset yellow", "carmoisine", "e102", "e110", "e122", "e129", "e133", "e150")):
out.append("Contains added colors (per ingredient list).")
return out

View File

@@ -0,0 +1,375 @@
from typing import List, Dict, Any, Optional
import json
import re
import requests
from app.infrastructure.settings import OLLAMA_BASE_URL, OLLAMA_MODEL_NAME, USE_OLLAMA, OLLAMA_TIMEOUT_SECONDS
from app.services.brand_registry import get_known_sub_brands
SYSTEM_PROMPT = (
"You are a product catalog assistant. Given a brand name, return a JSON object with "
"key 'products' (array). Each product has: "
"title, category, description (<=160 chars), size_variants (array like ['100g','200g']), "
"price_ranges (object like {'100g':'₹10','200g':'₹18'}), "
"price_range (string like '₹10-20'), "
"provider_examples (array like ['Amazon','Flipkart']). "
"Return ONLY valid JSON with no extra text."
)
def _ensure_client():
if not USE_OLLAMA:
return None
# Verify Ollama is reachable
try:
resp = requests.get(f"{OLLAMA_BASE_URL}/api/tags", timeout=5)
return resp.status_code == 200
except Exception:
return False
def _generate(system: str, user_prompt: str, max_retries: int = 2) -> str:
"""Call Ollama's chat endpoint and return text safely.
Retries up to `max_retries` times when the response is empty, since
small local models (e.g. qwen2.5:1.5b) sometimes return empty content
for complex JSON prompts on the first attempt.
"""
if not _ensure_client():
return ""
for attempt in range(max_retries + 1):
try:
resp = requests.post(
f"{OLLAMA_BASE_URL}/api/chat",
json={
"model": OLLAMA_MODEL_NAME,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
],
"stream": False,
"options": {"temperature": 0.6},
},
timeout=OLLAMA_TIMEOUT_SECONDS,
)
resp.raise_for_status()
data = resp.json()
content = (data.get("message", {}).get("content", "") or "").strip()
if content:
return content
if attempt < max_retries:
import time
time.sleep(1.0)
except Exception:
if attempt >= max_retries:
return ""
import time
time.sleep(1.0)
return ""
def _extract_json(text: str) -> dict | None:
"""Extract JSON from model response, trying multiple strategies.
Handles both JSON objects {...} and JSON arrays [...] since small
local models frequently return bare arrays instead of an object with
a ``products`` key.
"""
if not text:
return None
# Try fenced code block (object or array)
match = re.search(r"```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```", text)
if match:
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
pass
# Try first JSON value in text (greedy - object)
brace = re.search(r"\{[\s\S]*\}", text)
if brace:
try:
return json.loads(brace.group(0))
except json.JSONDecodeError:
pass
# Try first JSON value in text (greedy - array)
bracket = re.search(r"\[[\s\S]*\]", text)
if bracket:
try:
return json.loads(bracket.group(0))
except json.JSONDecodeError:
pass
# Try parsing entire text
try:
return json.loads(text.strip())
except json.JSONDecodeError:
pass
# Fallback: try to fix common issues
cleaned = text.strip()
cleaned = re.sub(r"(?<=[:,\[])\s*'", '"', cleaned)
cleaned = re.sub(r"'\s*(?=[,:\}\]])", '"', cleaned)
try:
return json.loads(cleaned)
except json.JSONDecodeError:
return None
RAG_SYSTEM_PROMPT = (
"You are a helpful shopping assistant for an Indian FMCG product catalog. "
"Answer the user's question using ONLY the product information given in the "
"CONTEXT section below. Each context item is a real product from the catalog, "
"already filtered to match the product type the user asked about. "
"Rules:\n"
"1. Base your answer strictly on the CONTEXT - do not invent products, prices, or facts.\n"
"2. If the CONTEXT doesn't contain enough information to answer, say so plainly and "
"suggest the user rephrase or pick a different brand.\n"
"3. When you mention a product, use its exact title from the CONTEXT.\n"
"4. Keep the answer concise (3-6 sentences unless a list is clearly more useful).\n"
"5. Mention prices/sizes when they are present in the CONTEXT and relevant to the question.\n"
"6. CATEGORY DISCIPLINE: only recommend a product if it genuinely is the type of product the "
"user asked for. Never recommend a different product type as a substitute (e.g. do not "
"recommend chips/namkeen/snacks when the user asked for biscuits, or vice versa) - if the "
"CONTEXT contains no products of the exact type requested, say plainly that none were found "
"in the catalog instead of listing unrelated items.\n"
"7. ATTRIBUTE HONESTY: if the user asked for a specific attribute (e.g. low sugar, gluten-free, "
"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."""
if not _ensure_client():
return _build_grounded_fallback_answer(query, context_block, requested_category)
history_block = ""
if history:
turns = []
for turn in history[-6:]: # keep prompts small on CPU-only hardware
role = turn.get("role", "user")
content = (turn.get("content") or "").strip()
if content:
turns.append(f"{role.upper()}: {content}")
if turns:
history_block = "PREVIOUS CONVERSATION:\n" + "\n".join(turns) + "\n\n"
category_block = (
f"REQUESTED PRODUCT TYPE: {requested_category}\n\n" if requested_category else ""
)
user_prompt = (
f"{history_block}"
f"{category_block}"
f"CONTEXT (retrieved catalog products, already filtered to the requested product type "
f"when one was detected):\n{context_block}\n\n"
f"QUESTION: {query}\n\n"
"Answer the question now, following the rules you were given."
)
answer = _generate(RAG_SYSTEM_PROMPT, user_prompt, max_retries=2)
if not answer:
return _build_grounded_fallback_answer(query, context_block, requested_category)
return answer.strip()
def generate_grounded_text(system_prompt: str, user_prompt: str) -> str:
"""Thin public wrapper around the private `_generate` helper, for
callers outside this module that need a plain grounded text
completion rather than the JSON-extraction flows below (e.g.
`nutrition_narrative_service.py`, which passes its own strict
"only rephrase these given facts" system prompt). Returns "" if
Ollama isn't reachable, exactly like every other function here -
callers are expected to have their own non-LLM fallback."""
return _generate(system_prompt, user_prompt, max_retries=1)
def get_categories_for_brand(brand: str) -> list[str]:
"""Ask Ollama to enumerate product categories for a brand."""
if not _ensure_client():
return []
known = get_known_sub_brands(brand)
known_context = f" Known products under this brand include: {', '.join(known)}." if known else ""
system = "You are a category enumerator. Given a brand name, return a JSON object with key 'categories' (array of strings). List all logical product categories for the brand."
user_prompt = (
f"Brand: {brand}\n"
f"Return JSON only with no extra text.{known_context}"
)
text = _generate(system, user_prompt)
if not text:
return []
data = _extract_json(text)
if data is None:
return []
cats = data.get("categories", [])
return [c for c in cats if isinstance(c, str) and c.strip()]
def fetch_brand_catalog_exhaustive(brand: str, max_products: int = 300) -> Dict[str, Any] | None:
"""Exhaustive Ollama-based discovery: categories first, then products per category, merging up to max_products."""
if not _ensure_client():
return None
categories = get_categories_for_brand(brand) or ["General"]
unique_titles: set[str] = set()
products_out: list[Dict[str, Any]] = []
known = get_known_sub_brands(brand)
known_context = f" Known products under this brand include: {', '.join(known)}." if known else ""
for category in categories:
if len(products_out) >= max_products:
break
per_cat_prompt = (
f"Brand: {brand}\nCategory: {category}\n"
"Return a JSON object with key 'products' (array). Each product must include: "
"title, category, optional description (<=160 chars), image_url, size_variants (array), price_ranges (object), provider_examples (array up to 5). "
"You can also return just a bare JSON array of products if preferred. "
f"Return at least 50 items if available; avoid duplicates within the category.{known_context}"
)
text = _generate(SYSTEM_PROMPT, per_cat_prompt)
if not text:
continue
parsed = _extract_json(text)
if parsed is None:
continue
try:
# Handle both dict-with-products-key and bare-array responses
raw_list = parsed if isinstance(parsed, list) else parsed.get("products", [])
for p in raw_list:
if not isinstance(p, dict):
continue
title = (p.get("title") or p.get("name") or "").strip()
if not title or title.lower() in unique_titles:
continue
unique_titles.add(title.lower())
out_item = {
"title": title,
"category": p.get("category") or category or "Uncategorized",
"description": p.get("description"),
"image_url": p.get("image_url") or p.get("imageUrl") or p.get("image"),
"size_variants": p.get("size_variants") or [],
"price_ranges": p.get("price_ranges") or {},
"provider_examples": p.get("provider_examples") or [],
}
products_out.append(out_item)
if len(products_out) >= max_products:
break
except Exception:
continue
return {"brand": brand, "products": products_out}
def fetch_product_details(brand: str, product_title: str) -> Dict[str, Any] | None:
"""Get details for a single product: description, image_url, pricing fields.
Returns a dict with keys: description, image_url, size_variants, price_ranges, price_range, provider_examples.
"""
if not _ensure_client():
return None
user_prompt = (
f"Brand: {brand}\nProduct: {product_title}\n"
"Return strictly JSON with keys: description, image_url?, size_variants?, price_ranges?, price_range?, provider_examples?.\n"
"description must be <=160 chars, concise and factual."
)
text = _generate(SYSTEM_PROMPT, user_prompt)
if not text:
return None
data = _extract_json(text)
if data is None:
return None
try:
return {
"description": data.get("description") or "",
"image_url": data.get("image_url") or data.get("imageUrl") or data.get("image"),
"size_variants": data.get("size_variants") or [],
"price_ranges": data.get("price_ranges") or {},
"price_range": data.get("price_range"),
"provider_examples": data.get("provider_examples") or [],
}
except Exception:
return None
def fetch_brand_catalog_with_gemini(brand: str) -> Dict[str, Any] | None:
if not _ensure_client():
print("LLM client not available (Ollama). Check config.")
return None
user_prompt = (
f"Brand: {brand}\n"
"Return a JSON object with key 'products' (array of objects), "
"each with: title, category, description, size_variants, price_range, provider_examples."
)
try:
print(f"Calling Ollama for brand: {brand}")
text = _generate(SYSTEM_PROMPT, user_prompt)
print(f"Ollama response length: {len(text)}")
parsed = _extract_json(text)
if parsed is None:
print("Failed to extract valid JSON from Ollama response")
return {"brand": brand, "products": []}
# Handle both dict-with-products-key and bare-array responses
if isinstance(parsed, list):
raw_products = parsed
elif isinstance(parsed, dict):
raw_products = parsed.get("products") or parsed.get("items") or []
else:
raw_products = []
out: Dict[str, Any] = {"brand": brand, "products": []}
for p in raw_products:
if not isinstance(p, dict):
continue
title = p.get("title") or p.get("name") or p.get("product") or "Unknown"
category = p.get("category") or "Uncategorized"
desc = p.get("description")
img = p.get("image_url") or p.get("imageUrl") or p.get("image")
size_variants = p.get("size_variants") or []
price_ranges = p.get("price_ranges") or {}
provider_examples = p.get("provider_examples") or []
out["products"].append({
"title": title,
"category": category,
"description": desc or "",
"image_url": img,
"size_variants": size_variants,
"price_ranges": price_ranges,
"provider_examples": provider_examples,
"price_range": p.get("price_range")
})
print(f"Parsed {len(out['products'])} products from Ollama")
return out
except Exception as e:
print(f"Error calling Ollama: {e}")
return None

View File

@@ -0,0 +1,129 @@
"""
Last-resort image search fallback using Python Playwright (sync API),
run as an isolated subprocess.
WHY THIS REPLACES THE OLD NODE.JS/CRAWLEE SCRIPTS
---------------------------------------------------
The previous implementation shelled out to `node simple_image_search.js`
/ `node fallback_image_search.js`, which depended on:
- Node.js being installed
- `npm install` having been run inside cli/ (node_modules present)
- `npx playwright install chromium` having been run for the Node copy
of Playwright
On a fresh checkout (exactly the state of the project as delivered to the
user), none of that npm/browser setup exists, so `catalog_engine.py`'s
`_check_crawlee_availability()` only checks `node --version` - it returns
True even though the actual `crawlee` package is never installed. Every
single product then spent up to 120s + 60s (two full subprocess timeouts)
calling Node scripts that immediately fail with "Cannot find module
'crawlee'" and silently return `[]`. That is the single biggest reason
"no image of product gets generated" - the pipeline was burning almost
all of its time on a dependency that was never actually installed, before
ever reaching a working fallback.
This module removes the Node/Crawlee runtime entirely. It uses Python
Playwright (already a project dependency in requirements.txt) directly,
run as its own OS subprocess via `python -m
app.services.playwright_image_fallback "<query>"` so it never shares an
asyncio event loop with Streamlit (the same greenlet/threading conflict
this project has already run into with Crawlee inside Streamlit).
It only runs as the LAST resort, after the free structured APIs
(Open Food/Beauty/Products Facts, Wikimedia Commons) and DuckDuckGo image
search have already been tried by `image_search.py`, since launching a
real Chromium browser is the slowest and heaviest option on an 8GB RAM,
CPU-only machine.
If Playwright's Chromium browser was never installed (`playwright install
chromium` not run), this fails fast and returns `[]` rather than hanging,
and the caller logs a clear one-time message telling the user how to fix
it - instead of two full minutes of silent timeouts per product.
"""
from __future__ import annotations
import json
import sys
def search_bing_images(query: str, max_results: int = 10) -> list:
"""Render a real Bing Image Search page with headless Chromium and
extract the embedded result metadata (`m="{...}"` JSON attribute on
`<a class="iusc">` elements). This is far more reliable than a plain
`requests` GET because Bing increasingly serves a JS-gated results
shell to non-browser clients."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("PLAYWRIGHT_NOT_INSTALLED", file=sys.stderr)
return []
ua = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
urls: list = []
try:
with sync_playwright() as p:
try:
browser = p.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"],
)
except Exception as e:
# Most common cause: `playwright install chromium` was
# never run. Fail fast instead of hanging.
print(f"PLAYWRIGHT_LAUNCH_FAILED: {e}", file=sys.stderr)
return []
try:
context = browser.new_context(user_agent=ua, locale="en-IN")
page = context.new_page()
page.goto(
f"https://www.bing.com/images/search?q={query}&form=HDRSC2",
timeout=20000,
wait_until="domcontentloaded",
)
try:
page.wait_for_selector("a.iusc", timeout=8000)
except Exception:
pass # proceed with whatever loaded
anchors = page.query_selector_all("a.iusc")
for a in anchors:
raw = a.get_attribute("m")
if not raw:
continue
try:
meta = json.loads(raw)
except Exception:
continue
murl = meta.get("murl")
if murl and str(murl).startswith("http") and murl not in urls:
urls.append(murl)
if len(urls) >= max_results:
break
finally:
browser.close()
except Exception as e:
print(f"PLAYWRIGHT_SEARCH_ERROR: {e}", file=sys.stderr)
return urls
return urls
def main() -> None:
if len(sys.argv) < 2:
print(json.dumps([]))
return
query = sys.argv[1]
max_results = int(sys.argv[2]) if len(sys.argv) > 2 else 10
try:
urls = search_bing_images(query, max_results)
except Exception:
urls = []
print(json.dumps(urls))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,355 @@
"""
Realistic Price Estimation Engine
==================================
Replaces the old context-free "size_in_grams * flat_rate" formula in
catalog_engine.py with a category + brand-tier aware model that is anchored
to real Indian retail MRP ranges (FMCG: food, personal care, household).
Why this exists
----------------
The previous `_generate_mock_price()` used a single flat ₹/gram rate per
broad category (e.g. 0.30 for "general") with no price floor. That produces
numbers that don't resemble real shelf prices - e.g. a 40g toothpaste came
out to ~₹19 (real MRP ~₹35-55), and a 1kg "family pack" snack came out to
~₹300+ because the formula scales linearly with weight with no diminishing
returns for bulk packaging.
This module instead:
1. Classifies the product into a finer-grained retail category.
2. Uses a curated ₹/100g (or ₹/100ml) anchor band per category, derived from
typical Indian MRPs (these are deliberately ranges, not single numbers).
3. Applies a non-linear pack-size curve (small packs cost more per gram,
large/family packs cost less per gram) instead of pure linear scaling.
4. Applies a minimum floor price per category so tiny sachets/trial packs
never come out near-zero.
5. Applies a brand-tier multiplier (premium / mainstream / value) using a
small curated brand list, defaulting to "mainstream" for unknown brands.
6. Offers `reconcile_llm_price()` to sanity-clamp whatever a (small, locally
run) LLM proposes against this anchor band, rather than trusting it
blindly - small models are unreliable at numeric estimation, but they
are still useful as a first pass that we now constrain instead of discard.
"""
from __future__ import annotations
import hashlib
import re
from typing import Optional, Tuple
# ---------------------------------------------------------------------------
# Category price bands: (₹ per 100g/100ml at "standard" pack size, floor ₹)
# Ranges are deliberately wide because retail prices vary by formulation -
# the jitter below picks a stable point inside the range per product.
# ---------------------------------------------------------------------------
CATEGORY_BANDS = {
# category_key: (min_rate_per_100, max_rate_per_100, floor_price_rs)
"biscuits_cookies": (9, 22, 5),
"crackers": (10, 24, 5),
"rusk": (7, 14, 5),
"bakery_bread": (10, 18, 15),
"cakes_muffins": (18, 38, 10),
"chocolates": (35, 90, 10),
"snacks_namkeen": (12, 28, 5),
"dairy": (10, 28, 10),
"beverages_juice": (8, 22, 10),
"beverages_tea_coffee": (25, 70, 15),
"breakfast_cereal": (20, 45, 25),
"oral_care": (45, 110, 25), # toothpaste, mouthwash, toothbrush packs
"hair_care": (35, 95, 20), # shampoo, conditioner, hair oil
"skin_bath": (30, 85, 15), # soap, body wash, lotion
"household_clean": (15, 40, 10),
"baby_care": (40, 110, 25),
"general": (15, 35, 10),
}
# Heuristic keyword routing -> category key (checked in order)
CATEGORY_KEYWORDS = [
(["toothpaste", "toothbrush", "mouthwash", "oral care"], "oral_care"),
(["shampoo", "conditioner", "hair oil", "hair serum"], "hair_care"),
(["soap", "body wash", "lotion", "moisturiser", "moisturizer", "face wash", "cream"], "skin_bath"),
(["detergent", "dishwash", "floor cleaner", "toilet cleaner", "handwash"], "household_clean"),
(["diaper", "baby wipes", "baby food", "baby lotion"], "baby_care"),
(["chocolate", "cocoa"], "chocolates"),
(["biscuit", "cookie"], "biscuits_cookies"),
(["cracker", "saltine"], "crackers"),
(["rusk"], "rusk"),
(["cake", "muffin", "pastry"], "cakes_muffins"),
(["bread", "bun", "loaf"], "bakery_bread"),
(["milk", "cheese", "butter", "paneer", "yogurt", "curd", "ghee"], "dairy"),
(["juice", "squash", "syrup"], "beverages_juice"),
(["tea", "coffee"], "beverages_tea_coffee"),
(["cereal", "muesli", "oats", "cornflakes"], "breakfast_cereal"),
(["chips", "namkeen", "snack", "wafer", "mixture"], "snacks_namkeen"),
]
# Small curated brand-tier list. Unknown brands default to "mainstream" (1.0x).
PREMIUM_BRANDS = {
"godrej expert", "loreal", "l'oreal", "olay", "dove", "nivea", "himalaya",
"forest essentials", "lakme", "ponds", "dabur", "patanjali ayurved",
}
VALUE_BRANDS = {
"patanjali", "local", "value", "economy",
}
TIER_MULTIPLIER = {"premium": 1.35, "value": 0.8, "mainstream": 1.0}
def classify_category(title: str, hint_category: Optional[str] = None) -> str:
"""Map a free-text product title / existing category guess to one of our
curated pricing-band categories."""
text = f"{hint_category or ''} {title or ''}".lower()
for keywords, key in CATEGORY_KEYWORDS:
if any(k in text for k in keywords):
return key
return "general"
def _brand_tier(brand: str) -> str:
b = (brand or "").strip().lower()
if b in PREMIUM_BRANDS:
return "premium"
if b in VALUE_BRANDS:
return "value"
return "mainstream"
def _parse_size_to_grams(size: str) -> float:
"""Parse a size string like '100g', '1kg', '250ml', '1L', '1l', '1.5 Litre',
'Family Pack' into a nominal gram/ml equivalent.
BUGFIX (root cause of e.g. "Dettol Liquid 500ml: ₹190, 1L: ₹25"):
the previous implementation used the regex r"\\bl\\b" to detect a bare
"L"/"l" unit. \\b is a *word*-boundary, and regex treats digits and
letters as the same "word" character class - so in a string like "1l"
(digit immediately followed by "l", no space), there is NO word
boundary between '1' and 'l' at all, and the pattern silently failed
to match. That made "1L" parse as 1 *gram* instead of 1000 grams,
so a 1-litre pack priced out far cheaper than a 500ml pack of the same
product - the unit multiplier was being dropped, not just inaccurate.
This version extracts the unit as its own explicit token (whatever
non-digit suffix follows the number, e.g. "l", "ltr", "litre", "kg",
"g", "ml") instead of relying on word-boundary regexes, so "1l", "1L",
"1 L", "1.5ltr", "2kg" etc. are all parsed correctly regardless of
spacing.
"""
if not size:
return 100.0
s = size.strip().lower()
match = re.search(r"(\d+(?:\.\d+)?)\s*([a-z]*)", s)
if not match or not match.group(1):
fallback = {
"family": 500, "jumbo": 400, "combo": 300, "value pack": 350,
"pack": 100, "piece": 20, "pcs": 20, "unit": 30, "trial": 15,
"mini": 25, "travel": 30,
}
for k, v in fallback.items():
if k in s:
return float(v)
return 100.0
value = float(match.group(1))
unit = match.group(2).strip()
# Litre-family units (any spacing/abbreviation): 1L = 1000ml
litre_units = {"l", "lt", "ltr", "ltrs", "litre", "litres", "liter", "liters"}
# Kilogram-family units: 1kg = 1000g
kg_units = {"kg", "kgs", "kilo", "kilos", "kilogram", "kilograms"}
# Millilitre/gram-family units (already base units) - no scaling needed.
base_units = {"ml", "mls", "g", "gm", "gms", "gram", "grams", ""}
if unit in litre_units:
value *= 1000
elif unit in kg_units:
value *= 1000
elif unit in base_units:
pass
else:
# Unrecognised unit token (e.g. "pcs", "pack", "x6") - leave the
# numeric value as-is rather than guessing.
pass
return value
def _pack_size_curve_multiplier(grams: float) -> float:
"""Small packs cost more per gram (packaging overhead, impulse-buy
pricing); large/family packs cost less per gram. This mirrors real
Indian FMCG shelf pricing far better than linear scaling."""
if grams <= 20:
return 2.2
if grams <= 50:
return 1.7
if grams <= 100:
return 1.25
if grams <= 200:
return 1.05
if grams <= 500:
return 0.92
if grams <= 1000:
return 0.8
return 0.7
def _deterministic_unit(seed_key: str) -> float:
"""Stable pseudo-random value in [0,1] derived from a seed string, so the
same product+size always resolves to the same price across runs."""
digest = hashlib.md5(seed_key.strip().lower().encode("utf-8")).hexdigest()
return int(digest[:8], 16) / 0xFFFFFFFF
def estimate_price(
size: str,
product_title: str = "",
brand: str = "",
category_hint: str = "",
) -> int:
"""Return a realistic INR price (integer) for a given pack size.
IMPORTANT: the per-100g/ml `rate_per_100` is derived from a seed that
deliberately EXCLUDES `size`. If the seed included the size string (as
it used to), each pack size of the same product would land on a
different random point inside the category's price-per-100 band, which
could - and did - make a *larger* pack cheaper in absolute terms than a
*smaller* pack of the exact same product (e.g. Dettol 500ml costing
more than Dettol 1L). Keeping the rate stable per product+brand+category
means every size variant of one product shares the same ₹/100(g|ml)
rate, so the only thing that changes between variants is `grams` and
the (size-aware, monotonic-by-design) pack-size curve multiplier below
- guaranteeing larger packs always cost more in absolute ₹, while
still costing less per gram/ml (realistic bulk-discount behaviour).
"""
category = classify_category(product_title, category_hint)
min_rate, max_rate, floor_price = CATEGORY_BANDS[category]
grams = _parse_size_to_grams(size)
seed = f"{brand}|{category}|{product_title}"
jitter = _deterministic_unit(seed)
rate_per_100 = min_rate + jitter * (max_rate - min_rate)
base_price = (grams / 100.0) * rate_per_100
base_price *= _pack_size_curve_multiplier(grams)
base_price *= TIER_MULTIPLIER[_brand_tier(brand)]
price = max(round(base_price), floor_price)
# Round to a "nice" retail-looking number (ends in 0/5/9) for anything
# above ₹20, mirroring common Indian MRP conventions.
if price >= 20:
price = int(round(price / 5.0) * 5)
return int(price)
def enforce_monotonic_prices(variants: list) -> list:
"""Given a list of (size_str, price_int) tuples, return a new list
sorted by ascending pack size where price never decreases as size
increases.
This is a defence-in-depth safety net on top of `estimate_price`'s own
seed fix above: it also catches the case where a per-variant price came
from `reconcile_llm_price` (which allows the LLM's own number through
if it's within [0.5x, 2x] of the anchor) and two *different* variants'
LLM numbers individually passed that check but still ended up
non-monotonic relative to each other - e.g. a hallucinated "₹25" for
the 1L variant and "₹190" for the 500ml variant could each
individually look plausible in isolation while still being wrong
relative to each other.
"""
if not variants:
return []
enriched = [
(size, price, _parse_size_to_grams(size if isinstance(size, str) else str(size)))
for size, price in variants
]
enriched.sort(key=lambda x: x[2])
fixed = []
last_price = 0
last_grams = None
for size, price, grams in enriched:
if last_grams is not None and grams > last_grams and price < last_price:
# A strictly larger pack must not cost less than the previous
# (strictly smaller) one. Bump it up to at least match.
price = last_price
fixed.append((size, price))
last_price = max(last_price, price)
last_grams = grams
return fixed
def estimate_price_range(
sizes: list,
product_title: str = "",
brand: str = "",
category_hint: str = "",
) -> Tuple[int, int]:
"""Estimate a (min, max) price range across a list of size variants."""
if not sizes:
category = classify_category(product_title, category_hint)
_, _, floor_price = CATEGORY_BANDS[category]
lo = estimate_price("100g", product_title, brand, category_hint)
return (floor_price, max(lo, floor_price))
prices = [estimate_price(s, product_title, brand, category_hint) for s in sizes]
return (min(prices), max(prices))
def default_size_variants(category_hint: str, product_title: str = "") -> list:
"""Category-appropriate realistic default pack sizes, used only when the
LLM/scraper provided none at all. Replaces the old one-size-fits-all
["100g", "200g", "500g"] default, which made no sense for e.g. toothpaste
or shampoo."""
category = classify_category(product_title, category_hint)
presets = {
"oral_care": ["40g", "80g", "150g"],
"hair_care": ["90ml", "180ml", "340ml"],
"skin_bath": ["75g", "100g", "125g"],
"household_clean": ["500ml", "1L", "2L"],
"baby_care": ["100ml", "200ml", "400g"],
"chocolates": ["20g", "55g", "150g"],
"biscuits_cookies": ["100g", "200g", "375g"],
"crackers": ["100g", "200g", "300g"],
"rusk": ["180g", "300g", "600g"],
"bakery_bread": ["200g", "400g", "600g"],
"cakes_muffins": ["100g", "250g", "400g"],
"snacks_namkeen": ["55g", "150g", "200g"],
"dairy": ["200ml", "500ml", "1L"],
"beverages_juice": ["200ml", "1L", "2L"],
"beverages_tea_coffee": ["100g", "250g", "500g"],
"breakfast_cereal": ["250g", "500g", "1kg"],
"general": ["100g", "250g", "500g"],
}
return presets.get(category, presets["general"])
def reconcile_llm_price(
llm_price_rs: Optional[float],
size: str,
product_title: str = "",
brand: str = "",
category_hint: str = "",
) -> int:
"""Sanity-clamp a price the (small, local) LLM proposed against our
curated retail anchor. Small models often hallucinate numbers that are
off by an order of magnitude; rather than discarding their guess
entirely or trusting it blindly, we keep it only if it falls within a
plausible band [0.5x, 2x] of our anchor estimate, otherwise we fall back
to the anchor estimate itself.
"""
anchor = estimate_price(size, product_title, brand, category_hint)
if not llm_price_rs or llm_price_rs <= 0:
return anchor
if anchor * 0.5 <= llm_price_rs <= anchor * 2.0:
price = int(round(llm_price_rs))
return price
return anchor
def parse_price_string(text: str) -> Optional[float]:
"""Extract a single numeric INR value from a free-text price string like
'₹45-60', '₹120', '50.5', or 'around ₹30'. Returns the average of a
range when one is present."""
if not text:
return None
numbers = [float(n) for n in re.findall(r"\d+(?:\.\d+)?", str(text))]
if not numbers:
return None
if len(numbers) >= 2:
return (numbers[0] + numbers[1]) / 2.0
return numbers[0]

View File

@@ -0,0 +1,202 @@
"""
Lightweight intent parsing for free-text RAG queries.
Deliberately regex/keyword based (no extra model call) so it stays fast
and free on CPU-only hardware. Two things are extracted:
1. The product category the user is asking about, via
`category_registry.detect_category_from_text()` - re-exported here
for convenience so callers only need to import this module.
2. Simple product attributes (sugar level, spice level, dietary
flags) mentioned in the query, used to *re-rank* (never to
silently fabricate) retrieved products - see
`rag_service._rerank_by_attributes()`.
"""
from __future__ import annotations
import re
from typing import Dict, Optional
from app.services.category_registry import detect_category_from_text # noqa: F401 (re-exported)
# Each attribute maps a canonical value to the query phrases that imply it.
# Order matters: more specific phrases (e.g. "sugar free") are checked
# before more general ones (e.g. "low sugar").
_ATTRIBUTE_PATTERNS: Dict[str, Dict[str, list]] = {
"sugar": {
"none": [r"no added sugar", r"no sugar", r"sugar[- ]?free", r"zero sugar"],
"low": [r"low sugar", r"less sugar", r"reduced sugar"],
"high": [r"extra sweet", r"very sweet"],
},
"spice": {
"high": [r"spicy", r"extra hot", r"very hot", r"chilli", r"chili"],
"low": [r"mild", r"less spicy", r"not (?:too )?spicy"],
},
"fat": {
"low": [r"low fat", r"light\b"],
},
"gluten": {
"free": [r"gluten[- ]?free"],
},
"salt": {
"low": [r"low salt", r"less salt", r"low sodium"],
},
}
# Keyword hints (per attribute/value) to look for in a product's own text
# (description + highlights + nutrients) when deciding whether it actually
# satisfies the attribute the user asked for. Kept separate from the query
# patterns above because product copy tends to use slightly different
# phrasing than a user typing a search.
_PRODUCT_EVIDENCE_HINTS: Dict[str, Dict[str, list]] = {
"sugar": {
"none": ["no added sugar", "sugar free", "sugar-free", "zero sugar"],
"low": ["low sugar", "less sugar", "reduced sugar", "no added sugar", "sugar free"],
},
"spice": {
"high": ["spicy", "chilli", "chili", "hot & spicy", "extra hot"],
"low": ["mild", "less spicy"],
},
"fat": {
"low": ["low fat", "light"],
},
"gluten": {
"free": ["gluten free", "gluten-free"],
},
"salt": {
"low": ["low salt", "low sodium", "less salt"],
},
}
def extract_attributes(query: str) -> Dict[str, str]:
"""Return a dict of {attribute: value} implied by the query, e.g.
{"sugar": "low"} for "recommend biscuits with low sugar"."""
if not query:
return {}
lower = query.lower()
found: Dict[str, str] = {}
for attribute, values in _ATTRIBUTE_PATTERNS.items():
for value, patterns in values.items():
if any(re.search(p, lower) for p in patterns):
found[attribute] = value
break
return found
def product_matches_attribute(product_text: str, attribute: str, value: str) -> bool:
"""Whether a product's own text (description/highlights/nutrients)
contains evidence it satisfies the requested attribute value."""
hints = _PRODUCT_EVIDENCE_HINTS.get(attribute, {}).get(value)
if not hints:
return False
lower = (product_text or "").lower()
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)

351
app/services/rag_service.py Normal file
View File

@@ -0,0 +1,351 @@
"""
RAG (Retrieval-Augmented Generation) orchestration.
This is the module that was missing from the original project: everything
needed to turn a free-text question into a grounded answer using the
catalog already sitting in pgvector.
Pipeline (all CPU-only, tuned for an 8GB RAM / no-GPU machine):
user query
-> embed_texts() (sentence-transformers, MiniLM-L6-v2, 384-dim)
-> vector_store.semantic_search() (pgvector cosine similarity, per-brand or all-brand)
-> build_context() (format top-k products into a compact text block)
-> ollama_service.generate_rag_answer() (qwen2.5:1.5b via local Ollama)
-> RagAnswer(answer, sources)
Kept deliberately simple and dependency-light (no LangChain/LlamaIndex) so
it stays easy to run and reason about on constrained hardware.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from app.infrastructure.settings import (
RAG_DEFAULT_TOP_K,
RAG_MAX_TOP_K,
RAG_MAX_CONTEXT_CHARS,
RAG_MAX_DISTANCE,
)
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,
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 (
count_products_all_brands,
count_products_by_brand,
list_available_brands,
list_categories_for_brand,
semantic_search,
text_search,
)
logger = logging.getLogger(__name__)
@dataclass
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
category: Optional[str] = None
description: Optional[str] = None
price_range: Optional[str] = None
size_variants: List[str] = field(default_factory=list)
providers: List[str] = field(default_factory=list)
highlights: List[str] = field(default_factory=list)
nutrients: List[str] = field(default_factory=list)
fssai_license: Optional[str] = None
product_sku: Optional[str] = None
sku_source: Optional[str] = None
hsn_code: Optional[str] = None
final_selling_price: Optional[float] = None
selling_price: Optional[float] = None
barcode: Optional[str] = None
barcode_type: Optional[str] = None
distance: float = 1.0
@property
def similarity(self) -> float:
"""Convert pgvector cosine distance (0=identical) to an intuitive 0-1 score."""
return max(0.0, min(1.0, 1.0 - (self.distance / 2.0)))
def to_dict(self) -> Dict[str, Any]:
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,
"category": self.category,
"description": self.description,
"price_range": self.price_range,
"size_variants": self.size_variants,
"providers": self.providers,
"highlights": self.highlights,
"nutrients": self.nutrients,
"fssai_license": self.fssai_license,
"product_sku": self.product_sku,
"sku_source": self.sku_source,
"hsn_code": self.hsn_code,
"final_selling_price": self.final_selling_price,
"selling_price": self.selling_price,
"barcode": self.barcode,
"barcode_type": self.barcode_type,
"similarity": round(self.similarity, 4),
}
@dataclass
class RagAnswer:
answer: str
sources: List[RetrievedProduct]
query: str
brand: Optional[str]
detected_category: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"answer": self.answer,
"query": self.query,
"brand": self.brand,
"detected_category": self.detected_category,
"sources": [s.to_dict() for s in self.sources],
}
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()
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 and image_id:
s3_list = s3_service.get_product_image_urls(brand, image_id)
if s3_list:
final_urls = s3_list
primary_url = (final_urls[0] if final_urls else None) or db_single
if not primary_url and s3_service.enabled and image_id:
primary_url = s3_service.get_product_image_url(brand, image_id)
hsn = row.get("hsn_code") or row.get("HSN_Code") or row.get("hsn") or None
if hsn is not None:
hsn = str(hsn).strip() or None
raw_fsp = row.get("final_selling_price") if "final_selling_price" in row else row.get("Final_Selling_Price")
if raw_fsp is None:
raw_fsp = row.get("final_price")
try:
fsp = float(raw_fsp) if raw_fsp is not None and str(raw_fsp).strip() != "" else None
except (ValueError, TypeError):
fsp = None
raw_sp = row.get("selling_price") if "selling_price" in row else row.get("Selling_Price")
try:
sp = float(raw_sp) if raw_sp is not None and str(raw_sp).strip() != "" else None
except (ValueError, TypeError):
sp = None
if fsp is None and sp is not None:
fsp = sp
bcd = row.get("barcode") or row.get("Barcode") or None
if bcd is not None:
bcd = str(bcd).strip() or None
bcd_type = row.get("barcode_type") or row.get("Barcode_Type") or None
if bcd_type is not None:
bcd_type = str(bcd_type).strip() or None
fssai = row.get("fssai_license") or row.get("fssai") or row.get("fssai_number") or row.get("FSSAI_License") or row.get("fssai_lic_no") or None
if fssai is not None:
fssai = str(fssai).strip() or None
return RetrievedProduct(
image_id=image_id,
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,
category=row.get("category"),
description=row.get("description"),
price_range=row.get("price_range"),
size_variants=list(row.get("size_variants") or []),
providers=list(row.get("providers") or []),
highlights=list(row.get("highlights") or []),
nutrients=list(row.get("nutrients") or []),
fssai_license=fssai,
product_sku=row.get("product_sku") or None,
sku_source=row.get("sku_source") or None,
hsn_code=hsn,
final_selling_price=fsp,
selling_price=sp,
barcode=bcd,
barcode_type=bcd_type,
distance=float(row.get("distance", 1.0)),
)
def _filter_to_category(rows: List[Dict[str, Any]], target_category: Optional[str]) -> List[Dict[str, Any]]:
if not target_category:
return rows
return [r for r in rows if category_matches(r.get("category"), target_category)]
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.
"""
def evidence_count(p: RetrievedProduct) -> int:
text = " ".join(filter(None, [p.description or "", " ".join(p.highlights), " ".join(p.nutrients)]))
return sum(
1 for attribute, value in attrs.items()
if product_matches_attribute(text, attribute, value)
)
indexed = list(enumerate(products))
indexed.sort(key=lambda pair: (-evidence_count(pair[1]), pair[0]))
return [p for _, p in indexed]
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 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])
except Exception as e:
logger.warning("Embedding model failed: %s. Using text fallback.", e)
vectors = None
rows: List[Dict[str, Any]] = []
if vectors:
rows = semantic_search(
query_embedding=vectors[0],
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",
query, target_category,
)
if not rows:
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]
attrs = extract_attributes(query)
if attrs and products:
products = _rerank_by_attributes(products, attrs)
return products
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 ""
if not products:
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 = []
for i, p in enumerate(products, start=1):
desc = (p.description or "").strip()
budget_for_desc = max(40, per_item_budget - 120)
if len(desc) > budget_for_desc:
desc = desc[:budget_for_desc].rsplit(" ", 1)[0] + "..."
parts = [f"{i}. {p.product_name or p.title} (brand: {p.brand}, category: {p.category or 'Uncategorized'})"]
if p.price_range:
parts.append(f" Price: {p.price_range}")
if p.size_variants:
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 prefix + "\n\n".join(lines)
def answer_query(
query: str,
brand: Optional[str] = None,
top_k: Optional[int] = None,
category: Optional[str] = None,
history: Optional[List[Dict[str, str]]] = None,
) -> RagAnswer:
"""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, 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=target_brand or brand,
detected_category=target_category,
)

View File

@@ -0,0 +1,110 @@
"""
Thin I/O orchestration layer for Feature 7 (Recommendation Engine).
Fetches what `app/intelligence/recommendation_engine.py`'s pure scoring
functions need, then persists/reads the result cache via `store_db.py`.
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
import numpy as np
import pandas as pd
from app.intelligence import recommendation_engine as RE
from app.intelligence.popularity_model import popularity_scorer
from app.services import store_db
logger = logging.getLogger(__name__)
_copurchase_cache: Dict[str, tuple] = {}
def _get_copurchase_matrix():
"""Cached per-process (not per-request) since it's rebuilt from the
full order history and is moderately expensive - recomputed when
`refresh()` is called (e.g. after the seed/training script runs)."""
if "matrix" not in _copurchase_cache:
order_items = store_db.get_order_items_df()
matrix, keys = RE.build_copurchase_matrix(order_items)
_copurchase_cache["matrix"] = matrix
_copurchase_cache["keys"] = keys
return _copurchase_cache["matrix"], _copurchase_cache["keys"]
def refresh_copurchase_cache() -> None:
_copurchase_cache.clear()
_get_copurchase_matrix()
def recommend_for_product(brand: str, image_id: str, top_k: int = 5, use_cache: bool = True) -> List[Dict]:
"""Hybrid recommendation: embedding similarity + TF-IDF + collaborative
co-purchase + popularity, blended per `RE.RecommendationCandidate.hybrid_score`.
Every result carries its own similarity_score, as required."""
if use_cache:
cached = store_db.get_cached_recommendations(brand, image_id)
if cached:
return [
{"brand": c["rec_brand"], "image_id": c["rec_image_id"],
"similarity_score": float(c["similarity_score"]), "method": c["method"],
"signals": c.get("signals_json")}
for c in cached[:top_k]
]
source_vec = store_db.get_embedding_vector(brand, image_id)
candidates_df = store_db.get_products_with_embeddings()
if candidates_df.empty:
return []
candidates_df = candidates_df[~((candidates_df["brand"] == brand) & (candidates_df["image_id"] == image_id))].reset_index(drop=True)
if candidates_df.empty:
return []
# 1) Embedding similarity
if source_vec:
cand_vecs = np.array(candidates_df["embedding"].tolist())
emb_sims = RE.embedding_similarity_to_source(np.array(source_vec), cand_vecs)
else:
emb_sims = np.zeros(len(candidates_df))
# 2) TF-IDF similarity over "title category brand"
source_row = store_db.get_products_with_embeddings(brands=[brand])
source_title = ""
src_match = source_row[source_row["image_id"] == image_id]
if not src_match.empty:
source_title = f"{src_match.iloc[0]['title']} {src_match.iloc[0]['category']} {brand}"
corpus = [source_title] + (candidates_df["title"].fillna("") + " " + candidates_df["category"].fillna("") + " " + candidates_df["brand"]).tolist()
tfidf_sims = RE.tfidf_similarity(corpus, source_index=0)[1:] if source_title else np.zeros(len(candidates_df))
# 3) Collaborative (co-purchase) similarity
matrix, keys = _get_copurchase_matrix()
collab_sims = np.zeros(len(candidates_df))
src_key = f"{brand}||{image_id}"
if src_key in keys and matrix.shape[0] > 0:
src_idx = keys.index(src_key)
row = matrix.getrow(src_idx).toarray().ravel()
key_to_pos = {k: i for i, k in enumerate(keys)}
for i, r in candidates_df.iterrows():
k = f"{r['brand']}||{r['image_id']}"
if k in key_to_pos:
collab_sims[i] = row[key_to_pos[k]]
# 4) Popularity (needs the same features popularity_model was trained on;
# if unavailable, falls back to a neutral 0 contribution via the scorer's
# own graceful-degradation path)
pop_norm = np.zeros(len(candidates_df))
candidates = [
RE.RecommendationCandidate(
brand=row["brand"], image_id=row["image_id"],
embedding_similarity=float(emb_sims[i]) if len(emb_sims) else 0.0,
tfidf_similarity=float(tfidf_sims[i]) if len(tfidf_sims) else 0.0,
collaborative_similarity=float(collab_sims[i]),
popularity_norm=float(pop_norm[i]),
)
for i, row in candidates_df.iterrows()
]
ranked = RE.rank_candidates(candidates, top_k=top_k)
to_save = [{"rec_brand": r["brand"], "rec_image_id": r["image_id"], "similarity_score": r["similarity_score"],
"signals": r["signals"], "method": "hybrid"} for r in ranked]
store_db.save_recommendations(brand, image_id, to_save)
return [{**r, "method": "hybrid"} for r in ranked]

301
app/services/s3_service.py Normal file
View File

@@ -0,0 +1,301 @@
"""
S3 Service for DigitalOcean Spaces integration
Handles image upload and management
"""
import asyncio
import logging
import uuid
from pathlib import Path
from typing import List, Optional
from urllib.parse import urlparse
import aiohttp
import boto3
from botocore.exceptions import ClientError
from app.infrastructure.settings import (
USE_S3, S3_ACCESS_KEY, S3_SECRET_KEY,
S3_ENDPOINT, S3_BUCKET, S3_REGION
)
from app.services.brand_registry import resolve_parent_brand
logger = logging.getLogger(__name__)
class S3Service:
"""Service for managing S3/DigitalOcean Spaces operations"""
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
api_endpoint = f"https://{S3_REGION}.digitaloceanspaces.com" if 'digitaloceanspaces.com' in S3_ENDPOINT else S3_ENDPOINT
self.client = boto3.client(
's3',
endpoint_url=api_endpoint,
aws_access_key_id=S3_ACCESS_KEY,
aws_secret_access_key=S3_SECRET_KEY,
region_name=S3_REGION
)
else:
self.client = None
logger.warning("S3 service disabled - missing configuration")
def generate_image_id(self, product_name: str = None) -> str:
"""Generate image ID from product name or fallback to UUID"""
if product_name:
# Sanitize product name for S3 folder
sanitized = product_name.lower()
# Replace spaces and special chars with underscores
sanitized = ''.join(c if c.isalnum() else '_' for c in sanitized)
# Remove multiple underscores and trim
sanitized = '_'.join(filter(None, sanitized.split('_')))
# Limit length and add unique suffix to avoid conflicts
if len(sanitized) > 50:
sanitized = sanitized[:50]
# Add short UUID suffix to ensure uniqueness
unique_suffix = str(uuid.uuid4())[:8]
return f"{sanitized}_{unique_suffix}"
return str(uuid.uuid4())
async def download_image(self, url: str, session: aiohttp.ClientSession) -> Optional[bytes]:
"""Download image from URL with improved anti-bot evasion.
Two targeted fixes over the previous version:
1. The Referer header is now derived from the image URL's own
domain instead of always being hardcoded to google.com - many
retailer CDNs (hotlink protection) reject requests whose
Referer doesn't match their own site, which was silently
killing a chunk of otherwise-valid image URLs.
2. Downloaded bytes below MIN_IMAGE_BYTES are now rejected here
(in addition to the upstream validation in image_search.py),
so 1x1 tracking pixels / broken placeholder images never reach
the S3 upload step.
"""
try:
from urllib.parse import urlparse
try:
from app.infrastructure.settings import MIN_IMAGE_BYTES
except ImportError:
MIN_IMAGE_BYTES = 3000
parsed = urlparse(url)
referer = f"{parsed.scheme}://{parsed.netloc}/" if parsed.netloc else 'https://www.google.com/'
# Enhanced headers to better mimic a real browser
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'image',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Site': 'cross-site',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Referer': referer,
}
# Add random delay to avoid rate limiting
import random
await asyncio.sleep(random.uniform(0.5, 2.0))
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=45)) as response:
if response.status == 200:
content_type = response.headers.get('content-type', '').lower()
if 'image' in content_type or url.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp')):
data = await response.read()
if len(data) >= MIN_IMAGE_BYTES:
return data
else:
logger.warning(f"Image too small ({len(data)} bytes, likely placeholder) for {url}")
else:
logger.warning(f"Invalid content type for image {url}: {content_type}")
elif response.status == 403:
logger.warning(f"HTTP 403 for image {url} - Site blocking requests")
elif response.status == 404:
logger.warning(f"HTTP 404 for image {url} - Image not found")
elif response.status == 429:
logger.warning(f"HTTP 429 for image {url} - Rate limited, waiting...")
await asyncio.sleep(5) # Wait longer for rate limits
else:
logger.warning(f"HTTP {response.status} for image {url}")
except asyncio.TimeoutError:
logger.warning(f"Timeout downloading image {url}")
except Exception as e:
logger.warning(f"Failed to download image {url}: {e}")
return None
def upload_image(self, image_data: bytes, image_id: str, filename: str, brand: str = None) -> bool:
"""Upload image to S3 bucket under daily/brands/{brand}/{image_id}/ folder.
BUGFIX: ContentType used to be hard-coded to 'image/jpeg' regardless
of the file's actual format. A PNG/WEBP/GIF served with a
mismatched Content-Type can fail to render (or render incorrectly)
in some browsers and in <img> tags that respect MIME type strictly.
Now derived from the filename's extension via the stdlib
`mimetypes` module, falling back to image/jpeg only if the
extension is unrecognized.
"""
if not self.enabled:
return False
try:
import mimetypes
content_type = mimetypes.guess_type(filename)[0] or 'image/jpeg'
# Create path: daily/brands/{brand}/{image_id}/{filename}
if brand:
storage_brand = resolve_parent_brand(brand)
key = f"daily/brands/{storage_brand.lower()}/{image_id}/{filename}"
else:
key = f"daily/brands/products/{image_id}/{filename}"
self.client.put_object(
Bucket=S3_BUCKET,
Key=key,
Body=image_data,
ContentType=content_type,
ACL='public-read'
)
logger.info(f"✅ Uploaded {key} to S3")
return True
except ClientError as e:
logger.error(f"❌ S3 upload failed for {key}: {e}")
return False
async def process_product_images(self, product: dict, image_urls: List[str], brand: str = None, max_images: int = 10):
"""
Download and upload exactly max_images images for a product
Returns tuple of (image_id, list of uploaded S3 public URLs)
"""
if not self.enabled or not image_urls:
return "", []
id_source = product.get('product_name') or product.get('title', 'unknown_product')
image_id = self.generate_image_id(id_source)
uploaded_urls = []
# Limit to max_images
images_to_process = image_urls[:max_images]
storage_brand_name = resolve_parent_brand(brand) if brand else "products"
brand_low = storage_brand_name.lower()
async with aiohttp.ClientSession() as session:
logger.info(f"📸 Processing {len(images_to_process)} images for {id_source}")
for i, url in enumerate(images_to_process):
try:
logger.info(f"Downloading image {i+1}/{len(images_to_process)}: {url}")
image_data = await self.download_image(url, session)
if image_data:
url_path = urlparse(url).path
ext = Path(url_path).suffix.lower()
if ext not in {'.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp'}:
ext = '.jpg'
filename = f"image_{i:03d}{ext}"
if self.upload_image(image_data, image_id, filename, brand):
key = f"daily/brands/{brand_low}/{image_id}/{filename}" if brand else f"daily/brands/products/{image_id}/{filename}"
public_url = self.get_public_url(key)
uploaded_urls.append(public_url)
logger.info(f"✅ Successfully uploaded image {i+1}/{len(images_to_process)}")
else:
logger.warning(f"❌ Failed to upload image {i+1}/{len(images_to_process)}")
else:
logger.warning(f"❌ Failed to download image {i+1}/{len(images_to_process)}: No data received")
# Small delay to avoid overwhelming servers
await asyncio.sleep(0.2)
except Exception as e:
logger.warning(f"❌ Error processing image {i+1}/{len(images_to_process)} ({url}): {e}")
brand_path = f"daily/brands/{brand_low}/" if brand else "daily/brands/products/"
logger.info(f"📸 Uploaded {len(uploaded_urls)}/{len(images_to_process)} images for product {product.get('title', 'Unknown')} to {brand_path}{image_id}")
cache_key = f"{brand_low}:{image_id}"
if uploaded_urls:
self._url_cache[cache_key] = uploaded_urls
return image_id, uploaded_urls
def get_public_url(self, key: str) -> str:
"""Construct public URL for an S3 object"""
if not self.enabled:
return ""
# For DigitalOcean Spaces with virtual-hosted style endpoint
# Example: https://nearle.sgp1.digitaloceanspaces.com/key
return f"{S3_ENDPOINT}/{key}"
def get_product_image_url(self, brand: str, image_id: str) -> str:
"""Construct or fetch the first image URL for a product in S3."""
if not self.enabled or not image_id or not brand:
return ""
storage_brand = resolve_parent_brand(brand) if brand else "products"
brand_low = storage_brand.lower() if brand else "products"
cache_key = f"{brand_low}:{image_id}"
if cache_key in self._url_cache and self._url_cache[cache_key]:
return self._url_cache[cache_key][0]
key = f"daily/brands/{brand_low}/{image_id}/image_000.jpg"
return self.get_public_url(key)
def get_product_image_urls(self, brand: str, image_id: str) -> List[str]:
"""List and return public URLs for all images in a product's S3 folder"""
if not self.enabled or not image_id:
return []
storage_brand = resolve_parent_brand(brand) if brand else brand
brand_low = storage_brand.lower() if brand else "products"
cache_key = f"{brand_low}:{image_id}"
if cache_key in self._url_cache:
return self._url_cache[cache_key]
try:
# Try specific prefix patterns to find existing data
prefixes = [
f"daily/brands/{brand_low}/{image_id}/",
f"daily/brands/products/{image_id}/",
]
# If image_id is already a full path-like string, try it directly
if '/' in image_id:
prefixes.insert(0, f"{image_id}/" if not image_id.endswith('/') else image_id)
for prefix in prefixes:
response = self.client.list_objects_v2(
Bucket=S3_BUCKET,
Prefix=prefix
)
if 'Contents' in response:
image_urls = []
for obj in response['Contents']:
key = obj['Key']
# Filter for image files and avoid the directory prefix itself
if key.lower().endswith(('.jpg', '.jpeg', '.png', '.webp', '.gif')) and key != prefix:
image_urls.append(self.get_public_url(key))
if image_urls:
logger.debug("Found %d images under prefix: %s", len(image_urls), prefix)
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:
logger.error(f"Failed to list images from S3 for {image_id}: {e}")
return []
# Global service instance
s3_service = S3Service()

877
app/services/store_db.py Normal file
View File

@@ -0,0 +1,877 @@
"""
Store Intelligence persistence layer.
Follows the exact same conventions as `vector_store.py` (raw psycopg,
no ORM, `_connect()` returns None and every function degrades to an
empty/no-op result when Postgres isn't reachable) so this module slots
into the existing architecture rather than introducing a second data-
access pattern. It reuses `vector_store._connect()` directly instead of
duplicating connection logic (DRY).
New tables (Feature 10), additive only - nothing here touches or drops
any existing `brand_*` table:
stores - the 5 simulated stores
store_inventory - per-store stock levels for each product
store_prices - per-store MRP/cost/selling price
orders - order headers (Feature 8)
order_items - order line items / sales facts (Feature 8)
discount_history - every ML discount prediction that's been served
trending_products - cached trending-model output (Feature 6)
demand_forecast - cached forecasting-model output (Feature 9)
recommendations - cached recommendation-engine output (Feature 7)
analytics_summary - periodic rollup snapshots (Feature 10)
Products are referenced everywhere by the composite key (brand,
image_id) - the same natural key `vector_store.py` already treats as
globally unique (image_id is UNIQUE NOT NULL per brand table and is
generated from brand+product+size, so collisions across brands aren't
expected in practice). There's no real FK constraint to a `brand_*`
table (there isn't one shared table to reference - each brand has its
own), so referential integrity is enforced at the application layer
(the seed script only provisions products that actually exist).
"""
from __future__ import annotations
import json
import logging
from datetime import date, datetime
from typing import Any, Dict, List, Optional
import pandas as pd
import psycopg
from psycopg.rows import dict_row
from app.services.vector_store import _connect # reuse the exact same connection logic
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
SCHEMA_DDL = """
CREATE TABLE IF NOT EXISTS stores (
store_id TEXT PRIMARY KEY,
store_name TEXT NOT NULL,
city TEXT,
tier TEXT NOT NULL DEFAULT 'standard',
footfall_index NUMERIC NOT NULL DEFAULT 20,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS store_inventory (
store_id TEXT NOT NULL REFERENCES stores(store_id) ON DELETE CASCADE,
brand TEXT NOT NULL,
image_id TEXT NOT NULL,
title TEXT,
category TEXT,
available_stock INTEGER NOT NULL DEFAULT 0,
reserved_stock INTEGER NOT NULL DEFAULT 0,
reorder_level INTEGER NOT NULL DEFAULT 0,
safety_stock INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (store_id, brand, image_id)
);
CREATE INDEX IF NOT EXISTS idx_store_inventory_product ON store_inventory(brand, image_id);
CREATE INDEX IF NOT EXISTS idx_store_inventory_stock ON store_inventory(store_id, available_stock);
CREATE TABLE IF NOT EXISTS store_prices (
store_id TEXT NOT NULL REFERENCES stores(store_id) ON DELETE CASCADE,
brand TEXT NOT NULL,
image_id TEXT NOT NULL,
mrp NUMERIC NOT NULL,
cost_price NUMERIC NOT NULL,
selling_price NUMERIC NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (store_id, brand, image_id)
);
CREATE INDEX IF NOT EXISTS idx_store_prices_product ON store_prices(brand, image_id);
CREATE TABLE IF NOT EXISTS orders (
order_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
store_id TEXT NOT NULL REFERENCES stores(store_id) ON DELETE CASCADE,
order_date TIMESTAMP NOT NULL,
payment_method TEXT,
order_value NUMERIC NOT NULL DEFAULT 0,
delivery_status TEXT
);
CREATE INDEX IF NOT EXISTS idx_orders_store_date ON orders(store_id, order_date);
CREATE INDEX IF NOT EXISTS idx_orders_customer ON orders(customer_id);
CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(order_date);
CREATE TABLE IF NOT EXISTS order_items (
id BIGSERIAL PRIMARY KEY,
order_id TEXT NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
store_id TEXT NOT NULL,
brand TEXT NOT NULL,
image_id TEXT NOT NULL,
quantity INTEGER NOT NULL,
unit_price NUMERIC NOT NULL,
line_total NUMERIC NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id);
CREATE INDEX IF NOT EXISTS idx_order_items_product ON order_items(brand, image_id);
CREATE INDEX IF NOT EXISTS idx_order_items_store_product ON order_items(store_id, brand, image_id);
CREATE TABLE IF NOT EXISTS discount_history (
id BIGSERIAL PRIMARY KEY,
store_id TEXT NOT NULL,
brand TEXT NOT NULL,
image_id TEXT NOT NULL,
original_price NUMERIC NOT NULL,
discount_pct NUMERIC NOT NULL,
final_price NUMERIC NOT NULL,
savings NUMERIC NOT NULL,
model_version TEXT,
features_json JSONB,
predicted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_discount_history_product ON discount_history(store_id, brand, image_id, predicted_at DESC);
CREATE TABLE IF NOT EXISTS trending_products (
id BIGSERIAL PRIMARY KEY,
window_label TEXT NOT NULL,
scope TEXT NOT NULL,
scope_value TEXT,
brand TEXT NOT NULL,
image_id TEXT NOT NULL,
trend_score NUMERIC NOT NULL,
rank INTEGER NOT NULL,
computed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_trending_lookup ON trending_products(window_label, scope, scope_value, rank);
CREATE TABLE IF NOT EXISTS demand_forecast (
id BIGSERIAL PRIMARY KEY,
store_id TEXT NOT NULL,
brand TEXT NOT NULL,
image_id TEXT NOT NULL,
forecast_avg_daily_units NUMERIC NOT NULL,
forecast_total_units NUMERIC NOT NULL,
horizon_days INTEGER NOT NULL DEFAULT 7,
model_version TEXT,
computed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_demand_forecast_lookup ON demand_forecast(store_id, brand, image_id, computed_at DESC);
CREATE TABLE IF NOT EXISTS recommendations (
id BIGSERIAL PRIMARY KEY,
source_brand TEXT NOT NULL,
source_image_id TEXT NOT NULL,
rec_brand TEXT NOT NULL,
rec_image_id TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'hybrid',
similarity_score NUMERIC NOT NULL,
signals_json JSONB,
computed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_recommendations_source ON recommendations(source_brand, source_image_id, similarity_score DESC);
CREATE TABLE IF NOT EXISTS analytics_summary (
id BIGSERIAL PRIMARY KEY,
scope TEXT NOT NULL,
scope_value TEXT,
period TEXT NOT NULL,
metrics_json JSONB NOT NULL,
computed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_analytics_summary_lookup ON analytics_summary(scope, scope_value, period, computed_at DESC);
"""
def ensure_store_intelligence_schema() -> bool:
"""Idempotent: safe to call on every API startup, mirrors how the
existing brand tables are lazily ensured via `ensure_brand_schema`."""
conn = _connect()
if not conn:
logger.warning("Store-intelligence schema not created: no DB connection")
return False
try:
with conn, conn.cursor() as cur:
# Migration check for pre-existing 'stores' table created under legacy schema
cur.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = 'stores'"
)
stores_cols = {row[0] for row in cur.fetchall()}
if stores_cols:
if "store_id" not in stores_cols:
cur.execute("ALTER TABLE stores ADD COLUMN IF NOT EXISTS store_id TEXT")
if "store_name" not in stores_cols:
cur.execute("ALTER TABLE stores ADD COLUMN IF NOT EXISTS store_name TEXT")
if "city" not in stores_cols:
cur.execute("ALTER TABLE stores ADD COLUMN IF NOT EXISTS city TEXT")
if "tier" not in stores_cols:
cur.execute("ALTER TABLE stores ADD COLUMN IF NOT EXISTS tier TEXT NOT NULL DEFAULT 'standard'")
if "footfall_index" not in stores_cols:
cur.execute("ALTER TABLE stores ADD COLUMN IF NOT EXISTS footfall_index NUMERIC NOT NULL DEFAULT 20")
if "name" in stores_cols:
cur.execute("ALTER TABLE stores ALTER COLUMN name DROP NOT NULL")
try:
cur.execute("ALTER TABLE stores ADD CONSTRAINT stores_store_id_unique UNIQUE (store_id)")
except Exception:
pass
cur.execute(SCHEMA_DDL)
logger.info("Store-intelligence schema ensured (stores, orders, discounts, trending, forecasts, recommendations, analytics_summary)")
return True
except Exception as e: # noqa: BLE001
logger.error(f"Failed to ensure store-intelligence schema: {e}")
return False
finally:
conn.close()
def _dict_cursor(conn: psycopg.Connection):
return conn.cursor(row_factory=dict_row)
def _cast_numeric(df: pd.DataFrame, columns: List[str]) -> pd.DataFrame:
"""Postgres NUMERIC columns come back from psycopg as `decimal.Decimal`
objects, which pandas/numpy/scikit-learn arithmetic chokes on
(`unsupported operand type(s) for -: 'decimal.Decimal' and 'float'`).
Every function in this module that returns a DataFrame destined for
the `intelligence/` package's pandas/numpy feature engineering casts
its NUMERIC columns to float here, once, right at the DB boundary -
callers never need to think about this."""
for col in columns:
if col in df.columns:
df[col] = df[col].astype(float)
return df
# ---------------------------------------------------------------------------
# Stores
# ---------------------------------------------------------------------------
def upsert_stores(stores: List[Dict[str, Any]]) -> int:
"""`stores`: list of dicts with store_id, store_name, city, tier, footfall_index."""
conn = _connect()
if not conn:
return 0
try:
with conn, conn.cursor() as cur:
for s in stores:
cur.execute(
"""
INSERT INTO stores (store_id, store_name, city, tier, footfall_index)
VALUES (%(store_id)s, %(store_name)s, %(city)s, %(tier)s, %(footfall_index)s)
ON CONFLICT (store_id) DO UPDATE SET
store_name = EXCLUDED.store_name, city = EXCLUDED.city,
tier = EXCLUDED.tier, footfall_index = EXCLUDED.footfall_index
""",
s,
)
return len(stores)
except Exception as e: # noqa: BLE001
logger.error(f"upsert_stores failed: {e}")
return 0
finally:
conn.close()
def list_stores() -> List[Dict[str, Any]]:
conn = _connect()
if not conn:
return []
try:
with conn, _dict_cursor(conn) as cur:
cur.execute("SELECT * FROM stores ORDER BY store_id")
rows = list(cur.fetchall())
for r in rows:
if r.get("footfall_index") is not None:
r["footfall_index"] = float(r["footfall_index"])
return rows
except Exception as e: # noqa: BLE001
logger.error(f"list_stores failed: {e}")
return []
finally:
conn.close()
def get_store(store_id: str) -> Optional[Dict[str, Any]]:
conn = _connect()
if not conn:
return None
try:
with conn, _dict_cursor(conn) as cur:
cur.execute("SELECT * FROM stores WHERE store_id = %s", (store_id,))
return cur.fetchone()
except Exception as e: # noqa: BLE001
logger.error(f"get_store failed: {e}")
return None
finally:
conn.close()
# ---------------------------------------------------------------------------
# Inventory + Pricing
# ---------------------------------------------------------------------------
def replace_store_catalog(store_id: str, provisioned_products: List[Any], titles: Dict[str, str]) -> int:
"""Replaces (delete + bulk insert) one store's entire inventory +
pricing in a single transaction - used by the seed script when
(re)provisioning stores. `titles` maps 'brand||image_id' -> product
title, since ProvisionedProduct doesn't carry the title itself.
"""
conn = _connect()
if not conn:
return 0
try:
with conn, conn.cursor() as cur:
cur.execute("DELETE FROM store_inventory WHERE store_id = %s", (store_id,))
cur.execute("DELETE FROM store_prices WHERE store_id = %s", (store_id,))
for p in provisioned_products:
title = titles.get(f"{p.brand}||{p.image_id}", p.brand)
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)
""",
(store_id, p.brand, p.image_id, title, p.category, p.available_stock,
p.reserved_stock, p.reorder_level, p.safety_stock),
)
cur.execute(
"""
INSERT INTO store_prices (store_id, brand, image_id, mrp, cost_price, selling_price)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(store_id, p.brand, p.image_id, p.mrp, p.cost_price, p.selling_price),
)
return len(provisioned_products)
except Exception as e: # noqa: BLE001
logger.error(f"replace_store_catalog failed for {store_id}: {e}")
return 0
finally:
conn.close()
def get_store_products(
store_id: str, category: Optional[str] = None, in_stock_only: bool = False,
limit: int = 50, offset: int = 0,
) -> List[Dict[str, Any]]:
"""Joined inventory + pricing rows for one store, newest-priced-first
isn't meaningful here so ordered by title for a stable, browsable
listing."""
conn = _connect()
if not conn:
return []
try:
with conn, _dict_cursor(conn) as cur:
sql = """
SELECT i.store_id, i.brand, i.image_id, i.title, i.category,
i.available_stock, i.reserved_stock, i.reorder_level, i.safety_stock,
p.mrp, p.cost_price, p.selling_price
FROM store_inventory i
JOIN store_prices p ON p.store_id = i.store_id AND p.brand = i.brand AND p.image_id = i.image_id
WHERE i.store_id = %s
"""
params: List[Any] = [store_id]
if category:
sql += " AND i.category ILIKE %s"
params.append(f"%{category}%")
if in_stock_only:
sql += " AND i.available_stock > 0"
sql += " ORDER BY i.title LIMIT %s OFFSET %s"
params.extend([limit, offset])
cur.execute(sql, params)
rows = list(cur.fetchall())
for r in rows:
for f in ("mrp", "cost_price", "selling_price"):
if r.get(f) is not None:
r[f] = float(r[f])
return rows
except Exception as e: # noqa: BLE001
logger.error(f"get_store_products failed: {e}")
return []
finally:
conn.close()
def get_product_across_stores(brand: str, image_id: str) -> List[Dict[str, Any]]:
"""Every store that stocks this product, with that store's own price
and stock - powers the "compare this product across stores" view."""
conn = _connect()
if not conn:
return []
try:
with conn, _dict_cursor(conn) as cur:
cur.execute(
"""
SELECT i.store_id, s.store_name, s.tier, i.available_stock, i.reorder_level,
i.safety_stock, p.mrp, p.cost_price, p.selling_price
FROM store_inventory i
JOIN store_prices p ON p.store_id = i.store_id AND p.brand = i.brand AND p.image_id = i.image_id
JOIN stores s ON s.store_id = i.store_id
WHERE i.brand = %s AND i.image_id = %s
ORDER BY p.selling_price
""",
(brand, image_id),
)
rows = list(cur.fetchall())
for r in rows:
for f in ("mrp", "cost_price", "selling_price", "footfall_index"):
if r.get(f) is not None:
r[f] = float(r[f])
return rows
except Exception as e: # noqa: BLE001
logger.error(f"get_product_across_stores failed: {e}")
return []
finally:
conn.close()
def get_all_store_products_df() -> pd.DataFrame:
"""Every (store, product) inventory+price row across all stores, as a
DataFrame - the primary input to feature engineering for the
discount/forecast models."""
conn = _connect()
if not conn:
return pd.DataFrame()
try:
with conn, _dict_cursor(conn) as cur:
cur.execute(
"""
SELECT i.store_id, s.tier AS store_tier, i.brand, i.image_id, i.title, i.category,
i.available_stock, i.reserved_stock, i.reorder_level, i.safety_stock,
p.mrp, p.cost_price, p.selling_price
FROM store_inventory i
JOIN store_prices p ON p.store_id = i.store_id AND p.brand = i.brand AND p.image_id = i.image_id
JOIN stores s ON s.store_id = i.store_id
"""
)
rows = cur.fetchall()
return _cast_numeric(pd.DataFrame(rows), ["mrp", "cost_price", "selling_price"])
except Exception as e: # noqa: BLE001
logger.error(f"get_all_store_products_df failed: {e}")
return pd.DataFrame()
finally:
conn.close()
# ---------------------------------------------------------------------------
# Orders (Feature 8)
# ---------------------------------------------------------------------------
def bulk_insert_orders(orders_df: pd.DataFrame, order_items_df: pd.DataFrame) -> Dict[str, int]:
conn = _connect()
if not conn or orders_df.empty:
return {"orders": 0, "order_items": 0}
try:
with conn, conn.cursor() as cur:
cur.executemany(
"""
INSERT INTO orders (order_id, customer_id, store_id, order_date, payment_method, order_value, delivery_status)
VALUES (%(order_id)s, %(customer_id)s, %(store_id)s, %(order_date)s, %(payment_method)s, %(order_value)s, %(delivery_status)s)
ON CONFLICT (order_id) DO NOTHING
""",
orders_df.to_dict("records"),
)
if not order_items_df.empty:
cur.executemany(
"""
INSERT INTO order_items (order_id, store_id, brand, image_id, quantity, unit_price, line_total)
VALUES (%(order_id)s, %(store_id)s, %(brand)s, %(image_id)s, %(quantity)s, %(unit_price)s, %(line_total)s)
""",
order_items_df.to_dict("records"),
)
return {"orders": len(orders_df), "order_items": len(order_items_df)}
except Exception as e: # noqa: BLE001
logger.error(f"bulk_insert_orders failed: {e}")
return {"orders": 0, "order_items": 0}
finally:
conn.close()
def clear_orders() -> None:
"""Used by the seed script's `--reset` flag before re-simulating
order history, so re-runs don't duplicate data."""
conn = _connect()
if not conn:
return
try:
with conn, conn.cursor() as cur:
cur.execute("DELETE FROM order_items")
cur.execute("DELETE FROM orders")
except Exception as e: # noqa: BLE001
logger.error(f"clear_orders failed: {e}")
finally:
conn.close()
def get_orders_df(store_id: Optional[str] = None, start_date: Optional[date] = None,
end_date: Optional[date] = None) -> pd.DataFrame:
conn = _connect()
if not conn:
return pd.DataFrame()
try:
with conn, _dict_cursor(conn) as cur:
sql = "SELECT * FROM orders WHERE 1=1"
params: List[Any] = []
if store_id:
sql += " AND store_id = %s"
params.append(store_id)
if start_date:
sql += " AND order_date >= %s"
params.append(start_date)
if end_date:
sql += " AND order_date <= %s"
params.append(end_date)
cur.execute(sql, params)
df = pd.DataFrame(cur.fetchall())
if not df.empty:
df["order_date"] = pd.to_datetime(df["order_date"])
df = _cast_numeric(df, ["order_value"])
return df
except Exception as e: # noqa: BLE001
logger.error(f"get_orders_df failed: {e}")
return pd.DataFrame()
finally:
conn.close()
def get_order_items_df(store_id: Optional[str] = None) -> pd.DataFrame:
conn = _connect()
if not conn:
return pd.DataFrame()
try:
with conn, _dict_cursor(conn) as cur:
sql = """
SELECT oi.*, o.order_date, o.customer_id
FROM order_items oi JOIN orders o ON o.order_id = oi.order_id
"""
params: List[Any] = []
if store_id:
sql += " WHERE oi.store_id = %s"
params.append(store_id)
cur.execute(sql, params)
df = pd.DataFrame(cur.fetchall())
if not df.empty:
df["order_date"] = pd.to_datetime(df["order_date"])
df = _cast_numeric(df, ["unit_price", "line_total"])
return df
except Exception as e: # noqa: BLE001
logger.error(f"get_order_items_df failed: {e}")
return pd.DataFrame()
finally:
conn.close()
# ---------------------------------------------------------------------------
# Discount history (Feature 3)
# ---------------------------------------------------------------------------
def log_discounts(rows: List[Dict[str, Any]]) -> int:
"""`rows`: store_id, brand, image_id, original_price, discount_pct,
final_price, savings, model_version, features (dict)."""
conn = _connect()
if not conn or not rows:
return 0
try:
with conn, conn.cursor() as cur:
cur.executemany(
"""
INSERT INTO discount_history
(store_id, brand, image_id, original_price, discount_pct, final_price, savings, model_version, features_json)
VALUES (%(store_id)s, %(brand)s, %(image_id)s, %(original_price)s, %(discount_pct)s,
%(final_price)s, %(savings)s, %(model_version)s, %(features_json)s)
""",
[{**r, "features_json": json.dumps(r.get("features", {}))} for r in rows],
)
return len(rows)
except Exception as e: # noqa: BLE001
logger.error(f"log_discounts failed: {e}")
return 0
finally:
conn.close()
def get_latest_discounts(store_id: str, limit: int = 100) -> List[Dict[str, Any]]:
conn = _connect()
if not conn:
return []
try:
with conn, _dict_cursor(conn) as cur:
cur.execute(
"""
SELECT DISTINCT ON (brand, image_id) *
FROM discount_history
WHERE store_id = %s
ORDER BY brand, image_id, predicted_at DESC
LIMIT %s
""",
(store_id, limit),
)
return list(cur.fetchall())
except Exception as e: # noqa: BLE001
logger.error(f"get_latest_discounts failed: {e}")
return []
finally:
conn.close()
# ---------------------------------------------------------------------------
# Trending cache (Feature 6)
# ---------------------------------------------------------------------------
def save_trending(window_label: str, scope: str, scope_value: Optional[str], ranked: List[Dict[str, Any]]) -> int:
conn = _connect()
if not conn:
return 0
try:
with conn, conn.cursor() as cur:
cur.execute(
"DELETE FROM trending_products WHERE window_label = %s AND scope = %s AND scope_value IS NOT DISTINCT FROM %s",
(window_label, scope, scope_value),
)
cur.executemany(
"""
INSERT INTO trending_products (window_label, scope, scope_value, brand, image_id, trend_score, rank)
VALUES (%(window_label)s, %(scope)s, %(scope_value)s, %(brand)s, %(image_id)s, %(trend_score)s, %(rank)s)
""",
[{**r, "window_label": window_label, "scope": scope, "scope_value": scope_value} for r in ranked],
)
return len(ranked)
except Exception as e: # noqa: BLE001
logger.error(f"save_trending failed: {e}")
return 0
finally:
conn.close()
def get_trending(window_label: str, scope: str, scope_value: Optional[str] = None, top_k: int = 10) -> List[Dict[str, Any]]:
conn = _connect()
if not conn:
return []
try:
with conn, _dict_cursor(conn) as cur:
cur.execute(
"""
SELECT * FROM trending_products
WHERE window_label = %s AND scope = %s AND scope_value IS NOT DISTINCT FROM %s
ORDER BY rank ASC LIMIT %s
""",
(window_label, scope, scope_value, top_k),
)
return list(cur.fetchall())
except Exception as e: # noqa: BLE001
logger.error(f"get_trending failed: {e}")
return []
finally:
conn.close()
# ---------------------------------------------------------------------------
# Demand forecast cache (Feature 9)
# ---------------------------------------------------------------------------
def save_demand_forecasts(rows: List[Dict[str, Any]]) -> int:
conn = _connect()
if not conn or not rows:
return 0
try:
with conn, conn.cursor() as cur:
cur.executemany(
"""
INSERT INTO demand_forecast
(store_id, brand, image_id, forecast_avg_daily_units, forecast_total_units, horizon_days, model_version)
VALUES (%(store_id)s, %(brand)s, %(image_id)s, %(forecast_avg_daily_units)s,
%(forecast_total_units)s, %(horizon_days)s, %(model_version)s)
""",
rows,
)
return len(rows)
except Exception as e: # noqa: BLE001
logger.error(f"save_demand_forecasts failed: {e}")
return 0
finally:
conn.close()
def get_latest_demand_forecast(store_id: str, brand: str, image_id: str) -> Optional[Dict[str, Any]]:
conn = _connect()
if not conn:
return None
try:
with conn, _dict_cursor(conn) as cur:
cur.execute(
"""
SELECT * FROM demand_forecast
WHERE store_id = %s AND brand = %s AND image_id = %s
ORDER BY computed_at DESC LIMIT 1
""",
(store_id, brand, image_id),
)
return cur.fetchone()
except Exception as e: # noqa: BLE001
logger.error(f"get_latest_demand_forecast failed: {e}")
return None
finally:
conn.close()
# ---------------------------------------------------------------------------
# Recommendations cache (Feature 7)
# ---------------------------------------------------------------------------
def save_recommendations(source_brand: str, source_image_id: str, recs: List[Dict[str, Any]]) -> int:
conn = _connect()
if not conn or not recs:
return 0
try:
with conn, conn.cursor() as cur:
cur.execute(
"DELETE FROM recommendations WHERE source_brand = %s AND source_image_id = %s",
(source_brand, source_image_id),
)
cur.executemany(
"""
INSERT INTO recommendations (source_brand, source_image_id, rec_brand, rec_image_id, method, similarity_score, signals_json)
VALUES (%(source_brand)s, %(source_image_id)s, %(rec_brand)s, %(rec_image_id)s, %(method)s, %(similarity_score)s, %(signals_json)s)
""",
[{**r, "source_brand": source_brand, "source_image_id": source_image_id,
"signals_json": json.dumps(r.get("signals", {}))} for r in recs],
)
return len(recs)
except Exception as e: # noqa: BLE001
logger.error(f"save_recommendations failed: {e}")
return 0
finally:
conn.close()
def get_cached_recommendations(source_brand: str, source_image_id: str) -> List[Dict[str, Any]]:
conn = _connect()
if not conn:
return []
try:
with conn, _dict_cursor(conn) as cur:
cur.execute(
"SELECT * FROM recommendations WHERE source_brand = %s AND source_image_id = %s ORDER BY similarity_score DESC",
(source_brand, source_image_id),
)
return list(cur.fetchall())
except Exception as e: # noqa: BLE001
logger.error(f"get_cached_recommendations failed: {e}")
return []
finally:
conn.close()
def get_products_with_embeddings(category: Optional[str] = None, brands: Optional[List[str]] = None) -> pd.DataFrame:
"""Fetch (brand, image_id, title, category, embedding) for every
product across all brand tables, optionally narrowed by category or
a brand allowlist to bound the candidate set.
WHY THIS EXISTS INSTEAD OF `vector_store.semantic_search`
------------------------------------------------------------
Integration-testing the recommendation engine against a real
Postgres instance surfaced a pre-existing issue in the RAG
pipeline: `get_brand_table_ddl` creates an IVFFLAT index with
pgvector's default `lists` parameter on every brand table
regardless of size. IVFFLAT's accuracy depends on `lists` being
tuned to the row count (roughly sqrt(rows)) and `probes` being
raised accordingly; with the default settings on a table of a few
dozen-to-low-hundreds rows (typical brand catalog size here), most
of the index's clusters end up nearly empty and a similarity query
can silently return zero or near-zero matches even though a plain
sequential scan finds them fine - confirmed directly against this
project's own seeded data during testing.
Rather than modifying `vector_store.py` / the existing RAG search
path (outside this task's scope, and risky to touch a working,
unrelated pipeline stage), the recommendation engine fetches its
- naturally bounded once filtered by category/brand - candidate
pool with a plain sequential scan here and computes cosine
similarity itself in `recommendation_engine.embedding_similarity_to_source`.
This sidesteps the ANN index entirely and is actually more
reliable at this catalog scale, not just a workaround. If the
catalog grows into the tens of thousands of products per brand,
revisit `get_brand_table_ddl` (tune `lists`, raise `probes`, or
switch to an HNSW index, which doesn't need row-count tuning) and
this function can go back to using `semantic_search` for a bigger
speed win.
"""
from app.services.vector_store import list_available_brands, _table_name, _table_exists
conn = _connect()
if not conn:
return pd.DataFrame()
target_brands = brands or list_available_brands()
rows: List[Dict[str, Any]] = []
try:
with conn, conn.cursor() as cur:
for brand in target_brands:
table = _table_name(brand)
if not _table_exists(cur, table):
continue
sql = f"SELECT image_id, title, category, embedding::text AS embedding_text FROM {table} WHERE embedding IS NOT NULL"
params: List[Any] = []
if category:
sql += " AND category ILIKE %s"
params.append(f"%{category}%")
try:
cur.execute(sql, params)
except Exception as e: # noqa: BLE001
logger.warning(f"get_products_with_embeddings failed for {table}: {e}")
continue
colnames = [d[0] for d in cur.description]
for row in cur.fetchall():
rec = dict(zip(colnames, row))
rec["brand"] = brand # brand_* tables don't store a brand column - it's implicit in the table itself
if rec.get("embedding_text"):
rec["embedding"] = json.loads(rec.pop("embedding_text"))
rows.append(rec)
return pd.DataFrame(rows)
except Exception as e: # noqa: BLE001
logger.error(f"get_products_with_embeddings failed: {e}")
return pd.DataFrame()
finally:
conn.close()
def get_embedding_vector(brand: str, image_id: str) -> Optional[List[float]]:
"""Fetch a single product's stored embedding as a plain Python list.
Cast to ::text in SQL rather than relying on the pgvector Python
type adapter (not registered anywhere else in this codebase either -
`vector_store.py` only ever uses the embedding inside SQL via `<=>`,
never fetches it back into Python, so this mirrors that same
text-cast-and-parse approach for the one place we do need it back)."""
from app.services.vector_store import _table_name, _table_exists
conn = _connect()
if not conn:
return None
try:
with conn, conn.cursor() as cur:
table = _table_name(brand)
if not _table_exists(cur, table):
return None
cur.execute(f"SELECT embedding::text FROM {table} WHERE image_id = %s", (image_id,))
row = cur.fetchone()
if not row or not row[0]:
return None
return json.loads(row[0])
except Exception as e: # noqa: BLE001
logger.error(f"get_embedding_vector failed for {brand}/{image_id}: {e}")
return None
finally:
conn.close()
# ---------------------------------------------------------------------------
# Analytics snapshots (Feature 10)
# ---------------------------------------------------------------------------
def save_analytics_snapshot(scope: str, scope_value: Optional[str], period: str, metrics: Dict[str, Any]) -> bool:
conn = _connect()
if not conn:
return False
try:
with conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO analytics_summary (scope, scope_value, period, metrics_json) VALUES (%s, %s, %s, %s)",
(scope, scope_value, period, json.dumps(metrics)),
)
return True
except Exception as e: # noqa: BLE001
logger.error(f"save_analytics_snapshot failed: {e}")
return False
finally:
conn.close()

View File

@@ -0,0 +1,76 @@
"""
Orchestrates Feature 1/2/8 seeding: provision the 5 stores with a
random product subset + independent pricing/stock, then simulate order
history. Called from both `scripts/seed_store_intelligence.py` (CLI,
the primary/documented way to run this) and the
`/api/admin/store-intelligence/seed` endpoint (convenience for
demoing without shell access) - kept in one place so the two entry
points can't drift apart.
"""
from __future__ import annotations
import logging
from datetime import date, timedelta
from typing import Dict
from app.intelligence.order_simulation import StoreCatalogEntry, simulate_orders
from app.intelligence.store_provisioning import DEFAULT_STORES, ProductRef, provision_stores
from app.services import store_db
from app.services.recommendation_service import refresh_copurchase_cache
from app.services.vector_store import get_products_all_brands
logger = logging.getLogger(__name__)
def run_seed(reset_orders: bool = True, days: int = 90, seed: int = 42) -> Dict:
store_db.ensure_store_intelligence_schema()
logger.info("Fetching full catalog across all brands...")
products_raw = get_products_all_brands(limit=20000)
if not products_raw:
raise RuntimeError(
"No products found across any brand table. Ingest a catalog first "
"(POST /api/catalog/generate or scripts/seed_sample_data.py) before "
"seeding store intelligence."
)
refs = [
ProductRef(p["brand"], p["image_id"], p.get("title") or p.get("product_name"), p.get("category"), p.get("price_range"))
for p in products_raw
]
titles = {f"{r.brand}||{r.image_id}": r.title for r in refs}
logger.info("Provisioning %d stores over %d products...", len(DEFAULT_STORES), len(refs))
stores_meta = [
{"store_id": s.store_id, "store_name": s.store_name, "city": s.city, "tier": s.tier, "footfall_index": s.footfall_index}
for s in DEFAULT_STORES
]
store_db.upsert_stores(stores_meta)
provisioned = provision_stores(refs, seed=seed)
store_product_counts: Dict[str, int] = {}
for store_id, items in provisioned.items():
store_product_counts[store_id] = store_db.replace_store_catalog(store_id, items, titles)
logger.info("Simulating %d days of order history...", days)
store_products_df = store_db.get_all_store_products_df()
store_catalogs = {}
for store_id, g in store_products_df.groupby("store_id"):
store_catalogs[store_id] = [
StoreCatalogEntry(store_id, r["brand"], r["image_id"], r["category"], float(r["selling_price"]), int(r["available_stock"]))
for _, r in g.iterrows()
]
end = date.today()
start = end - timedelta(days=days)
result = simulate_orders(DEFAULT_STORES, store_catalogs, start, end, seed=seed)
if reset_orders:
store_db.clear_orders()
counts = store_db.bulk_insert_orders(result.orders, result.order_items)
refresh_copurchase_cache()
return {
"stores": len(DEFAULT_STORES),
"store_products": store_product_counts,
"orders": counts["orders"],
"order_items": counts["order_items"],
}

View File

@@ -0,0 +1,87 @@
"""
Thin I/O orchestration for Feature 6 (Trending Product Detection).
Computes trend features + scores live from order history, ranks within
the requested scope (overall / category / store), and caches the top-N
into `trending_products` so repeat reads for the same window/scope are
a cheap DB lookup instead of recomputation.
"""
from __future__ import annotations
import logging
from datetime import date
from typing import Dict, List, Optional
import pandas as pd
from app.intelligence.trending_model import Window, compute_trend_features, trending_scorer
from app.services import store_db
logger = logging.getLogger(__name__)
def compute_and_cache_trending(window: Window, top_k: int = 10) -> Dict[str, int]:
"""Recomputes and caches every trending scope for one window:
overall, each category, and each store. Meant to be run by the
training/seed script (or an admin trigger) rather than on every
request - the cached reads below serve actual traffic."""
order_items = store_db.get_order_items_df()
orders = store_db.get_orders_df()
if order_items.empty:
return {}
counts: Dict[str, int] = {}
# Overall (pooled across stores)
overall = compute_trend_features(order_items, orders, date.today(), window, ["brand", "image_id"])
overall["trend_score"] = trending_scorer.score(overall).values
ranked = _rank(overall)
store_db.save_trending(window, "overall", None, ranked[:top_k])
counts["overall"] = len(ranked)
# Category-wise: join category in via store_products, then re-rank within each category
store_products = store_db.get_all_store_products_df()
if not store_products.empty:
cat_map = store_products.drop_duplicates(["brand", "image_id"]).set_index(["brand", "image_id"])["category"]
overall_with_cat = overall.copy()
overall_with_cat["category"] = overall_with_cat.apply(
lambda r: cat_map.get((r["brand"], r["image_id"]), "Uncategorized"), axis=1
)
for category, g in overall_with_cat.groupby("category"):
ranked_cat = _rank(g)
store_db.save_trending(window, "category", category, ranked_cat[:top_k])
counts[f"category:{category}"] = len(ranked_cat)
# Store-wise
store_wise = compute_trend_features(order_items, orders, date.today(), window, ["store_id", "brand", "image_id"])
if not store_wise.empty:
store_wise["trend_score"] = trending_scorer.score(store_wise).values
for store_id, g in store_wise.groupby("store_id"):
ranked_store = _rank(g)
store_db.save_trending(window, "store", store_id, ranked_store[:top_k])
counts[f"store:{store_id}"] = len(ranked_store)
return counts
def _rank(df: pd.DataFrame) -> List[Dict]:
ranked = df.sort_values("trend_score", ascending=False).reset_index(drop=True)
return [
{"brand": r["brand"], "image_id": r["image_id"], "trend_score": round(float(r["trend_score"]), 2), "rank": i + 1}
for i, r in ranked.iterrows()
]
def get_trending(window: Window, scope: str = "overall", scope_value: Optional[str] = None, top_k: int = 10) -> List[Dict]:
cached = store_db.get_trending(window, scope, scope_value, top_k)
if cached:
return [{"brand": c["brand"], "image_id": c["image_id"], "trend_score": float(c["trend_score"]), "rank": c["rank"]} for c in cached]
# No cache yet (e.g. training script hasn't run) - compute this one
# scope live rather than returning an empty/hardcoded list.
order_items = store_db.get_order_items_df(store_id=scope_value if scope == "store" else None)
orders = store_db.get_orders_df()
group_cols = ["store_id", "brand", "image_id"] if scope == "store" else ["brand", "image_id"]
feat = compute_trend_features(order_items, orders, date.today(), window, group_cols)
if feat.empty:
return []
feat["trend_score"] = trending_scorer.score(feat).values
return _rank(feat)[:top_k]

View File

@@ -0,0 +1,800 @@
from __future__ import annotations
from typing import List, Optional, Dict, Any
import json
import logging
import re
import psycopg
from app.infrastructure.settings import DATABASE_URL, USE_PGVECTOR, DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD
from app.services.brand_registry import BRAND_ALIASES, resolve_parent_brand
from app.services.s3_service import s3_service
logger = logging.getLogger(__name__)
def _sanitize_name(name: str) -> str:
"""Sanitize a brand name for use as a PostgreSQL table name suffix.
Replaces non-alphanumeric characters (except underscore) to prevent
SQL syntax errors from special characters like &, ', ., etc.
"""
name = name.lower().replace(' ', '_').replace('-', '_').replace('&', '_')
name = re.sub(r'[^a-z0-9_]', '', name)
name = re.sub(r'_+', '_', name)
return name.strip('_')
DDL_CREATE_EXTENSION = "CREATE EXTENSION IF NOT EXISTS vector;"
def get_brand_table_ddl(brand: str) -> str:
"""Generate DDL for brand-specific table - simplified with only essential fields"""
storage_brand = resolve_parent_brand(brand)
table_name = f"brand_{_sanitize_name(storage_brand)}"
return f"""
CREATE TABLE IF NOT EXISTS {table_name} (
id BIGSERIAL PRIMARY KEY,
product_name TEXT NOT NULL,
title TEXT,
description TEXT,
category TEXT,
image_id TEXT UNIQUE NOT NULL,
image_url TEXT,
image_urls TEXT[],
-- Essential pricing fields
price_range TEXT,
size_variants TEXT[],
providers TEXT[],
-- FSSAI license
fssai_license TEXT,
-- Product SKU & Tax/Price/Barcode details
product_sku TEXT,
sku_source TEXT,
hsn_code TEXT,
final_selling_price NUMERIC,
selling_price NUMERIC,
barcode TEXT,
barcode_type TEXT,
-- Essential fields
highlights TEXT[],
nutrients TEXT[],
search_query TEXT,
-- Vector embedding for search
embedding vector(384),
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_{table_name}_image_id ON {table_name}(image_id);
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding ON {table_name} USING ivfflat (embedding vector_cosine_ops);
"""
def _connect() -> Optional[psycopg.Connection]:
if not USE_PGVECTOR:
return None
try:
# Use direct connection parameters instead of URL
return psycopg.connect(
host=DB_HOST,
port=DB_PORT,
dbname=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
autocommit=True
)
except Exception as e:
logger.error(f"Vector DB connection failed: {e}")
return None
def _ensure_columns(cur, table_name: str) -> None:
"""Add missing columns and relax legacy NOT NULL constraints for smooth schema migration."""
col_defs = {
"product_name": "TEXT",
"title": "TEXT",
"description": "TEXT",
"category": "TEXT",
"image_id": "TEXT",
"image_url": "TEXT",
"image_urls": "TEXT[]",
"price_range": "TEXT",
"size_variants": "TEXT[]",
"providers": "TEXT[]",
"fssai_license": "TEXT",
"product_sku": "TEXT",
"sku_source": "TEXT",
"hsn_code": "TEXT",
"final_selling_price": "NUMERIC",
"selling_price": "NUMERIC",
"barcode": "TEXT",
"barcode_type": "TEXT",
"highlights": "TEXT[]",
"nutrients": "TEXT[]",
"search_query": "TEXT",
"embedding": "vector(384)",
}
cur.execute(
f"SELECT column_name, is_nullable, column_default FROM information_schema.columns "
f"WHERE table_schema = 'public' AND table_name = %s",
(table_name,),
)
col_info = cur.fetchall()
existing = {row[0] for row in col_info}
# 1. Add missing columns
for col, col_type in col_defs.items():
if col not in existing:
cur.execute(f"ALTER TABLE {table_name} ADD COLUMN IF NOT EXISTS {col} {col_type}")
if col not in ("created_at", "updated_at"):
logger.info(f"Added missing column '{col}' to {table_name}")
# 2. Relax legacy NOT NULL constraints on columns not present in standard insert
inserted_cols = {"id", "product_name", "title", "description", "category", "image_id", "image_url", "image_urls", "price_range", "size_variants", "providers", "fssai_license", "product_sku", "sku_source", "hsn_code", "final_selling_price", "selling_price", "barcode", "barcode_type", "highlights", "nutrients", "search_query", "embedding", "created_at", "updated_at"}
for col, is_nullable, col_def in col_info:
if col not in inserted_cols and is_nullable == 'NO' and col_def is None:
cur.execute(f"ALTER TABLE {table_name} ALTER COLUMN {col} DROP NOT NULL")
logger.info(f"Dropped NOT NULL constraint on legacy column '{col}' in {table_name}")
# 3. Ensure UNIQUE index on image_id so ON CONFLICT (image_id) works on pre-existing tables
try:
cur.execute(
f"DELETE FROM {table_name} a USING {table_name} b "
f"WHERE a.id < b.id AND a.image_id = b.image_id AND a.image_id IS NOT NULL AND a.image_id != ''"
)
cur.execute(f"CREATE UNIQUE INDEX IF NOT EXISTS idx_{table_name}_image_id_unique ON {table_name}(image_id)")
except Exception as e:
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()
if not conn:
return ""
storage_brand = resolve_parent_brand(brand)
table_name = f"brand_{_sanitize_name(storage_brand)}"
with conn, conn.cursor() as cur:
try:
cur.execute(DDL_CREATE_EXTENSION)
except Exception:
pass
cur.execute(get_brand_table_ddl(brand))
_ensure_columns(cur, table_name)
logger.info(f"✅ Ensured table {table_name} exists")
conn.close()
return table_name
def upsert_brand_products(brand: str, products: List[Dict[str, Any]], cleanup: bool = False) -> None:
"""Insert products into brand-specific table - simplified with only essential fields
When `cleanup=True`, any products in the table whose image_id is NOT in the
provided `products` list are deleted after the upsert. This ensures the
database exactly reflects the source data. The caller is responsible for
providing the complete set of products for the brand when using cleanup.
"""
conn = _connect()
if not conn:
return
table_name = ensure_brand_schema(brand)
if not table_name:
return
rows = []
for p in products:
# Extract only essential fields
product_name = p.get("product_name") or p.get("title") or ""
title = p.get("title") or product_name
description = p.get("description") or ""
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]
if not image_urls and not image_url and image_id and s3_service.enabled:
s3_single = s3_service.get_product_image_url(brand, image_id)
if s3_single:
image_url = s3_single
image_urls = [s3_single]
# Essential pricing fields
price_range = p.get("price_range") or ""
size_variants = p.get("size_variants", [])
providers = p.get("providers", [])
# Convert size_variants objects to strings for TEXT[] storage
if size_variants and isinstance(size_variants, list):
size_variants_str = []
for variant in size_variants:
if isinstance(variant, dict):
# Convert dict to string format
size = variant.get('size', '')
price = variant.get('price', '')
if size and price:
size_variants_str.append(f"{size} - {price}")
elif size:
size_variants_str.append(size)
elif isinstance(variant, str):
size_variants_str.append(variant)
size_variants = size_variants_str
# Product SKU & HSN / Price / Barcode fields
product_sku = p.get("product_sku") or ""
sku_source = p.get("sku_source") or ""
hsn_code = str(p.get("hsn_code") or p.get("HSN_Code") or p.get("hsn") or "").strip() or None
raw_fsp = p.get("final_selling_price") if "final_selling_price" in p else p.get("Final_Selling_Price")
if raw_fsp is None:
raw_fsp = p.get("final_price")
try:
final_selling_price = float(raw_fsp) if raw_fsp is not None and str(raw_fsp).strip() != "" else None
except (ValueError, TypeError):
final_selling_price = None
raw_sp = p.get("selling_price") if "selling_price" in p else p.get("Selling_Price")
try:
selling_price = float(raw_sp) if raw_sp is not None and str(raw_sp).strip() != "" else None
except (ValueError, TypeError):
selling_price = None
if final_selling_price is None and selling_price is not None:
final_selling_price = selling_price
barcode = str(p.get("barcode") or p.get("Barcode") or "").strip() or None
barcode_type = str(p.get("barcode_type") or p.get("Barcode_Type") or "").strip() or None
# Essential fields
highlights = p.get("highlights", [])
if not isinstance(highlights, list):
highlights = []
# Nutrients field
nutrients = p.get("nutrients", [])
if not isinstance(nutrients, list):
nutrients = []
fssai_license = str(p.get("fssai_license", "")) if p.get("fssai_license") else ""
search_query = p.get("search_query") or ""
# Convert embedding to PostgreSQL vector format
embedding = p.get("embedding")
if embedding and isinstance(embedding, list):
# Convert list to string format for PostgreSQL vector type
embedding_str = "[" + ",".join(map(str, embedding)) + "]"
else:
embedding_str = None
# Only essential fields
rows.append((
product_name,
title,
description,
category,
image_id,
image_url,
image_urls,
price_range,
size_variants, # TEXT[] - psycopg will handle conversion
providers, # TEXT[] - psycopg will handle conversion
fssai_license,
product_sku,
sku_source,
hsn_code,
final_selling_price,
selling_price,
barcode,
barcode_type,
highlights, # TEXT[] - psycopg will handle conversion
nutrients, # TEXT[] - psycopg will handle conversion
search_query,
embedding_str
))
image_ids = [r[4] for r in rows if r[4]]
with conn, conn.cursor() as cur:
cur.executemany(
f"""
INSERT INTO {table_name}
(product_name, title, description, category, image_id, image_url, image_urls, price_range, size_variants, providers,
fssai_license, product_sku, sku_source, hsn_code, final_selling_price, selling_price, barcode, barcode_type, highlights, nutrients, search_query, embedding)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (image_id) DO UPDATE SET
product_name = EXCLUDED.product_name,
title = EXCLUDED.title,
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,
fssai_license = EXCLUDED.fssai_license,
product_sku = EXCLUDED.product_sku,
sku_source = EXCLUDED.sku_source,
hsn_code = EXCLUDED.hsn_code,
final_selling_price = EXCLUDED.final_selling_price,
selling_price = EXCLUDED.selling_price,
barcode = EXCLUDED.barcode,
barcode_type = EXCLUDED.barcode_type,
highlights = EXCLUDED.highlights,
nutrients = EXCLUDED.nutrients,
search_query = EXCLUDED.search_query,
embedding = EXCLUDED.embedding,
updated_at = CURRENT_TIMESTAMP
""",
rows,
)
logger.info(f"✅ Upserted {len(rows)} products into {table_name}")
# Remove stale products that were deleted from the source data.
# Only runs when cleanup=True so that callers processing partial
# product sets (e.g. multiple seed files contributing to the same
# brand table) don't accidentally orphan each other's data.
if cleanup and image_ids:
cur.execute(
f"DELETE FROM {table_name} WHERE image_id != ALL(%s::text[])",
(image_ids,),
)
deleted = cur.rowcount
if deleted:
logger.info(f"🗑️ Removed {deleted} stale product(s) from {table_name}")
conn.close()
def get_existing_product_image_id(brand: str, product_name: str) -> Optional[str]:
"""Check if a product with this name exists in the brand table and return its image_id"""
conn = _connect()
if not conn or not product_name:
return None
storage_brand = resolve_parent_brand(brand)
table_name = f"brand_{_sanitize_name(storage_brand)}"
try:
with conn.cursor() as cur:
cur.execute(f"""
SELECT image_id FROM {table_name}
WHERE product_name = %s
ORDER BY updated_at DESC
LIMIT 1
""", (product_name,))
row = cur.fetchone()
return row[0] if row else None
except Exception:
return None
finally:
conn.close()
def _build_sanitized_brand_map() -> Dict[str, str]:
"""Build a mapping from sanitized table suffix to original parent brand name.
This allows reverse-lookup of display names from sanitized table suffixes,
preserving proper casing and special characters like &.
"""
seen: Dict[str, str] = {}
for alias, parent in BRAND_ALIASES.items():
suffix = _sanitize_name(parent)
if suffix and suffix not in seen:
# Capitalize properly: handle special chars like & by preserving them
# from the original parent value
seen[suffix] = parent.strip().title()
return seen
def list_available_brands() -> List[str]:
"""List all available brands that have tables in the database"""
conn = _connect()
if not conn:
return []
brand_map = _build_sanitized_brand_map()
brands = []
with conn, conn.cursor() as cur:
try:
# Query information_schema for tables starting with brand_
cur.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name LIKE 'brand_%'
""")
tables = cur.fetchall()
for table in tables:
# Extract brand name from table name (e.g., brand_britannia -> britannia)
table_name = table[0]
suffix = table_name[len("brand_"):].lower() if table_name.startswith("brand_") else table_name.lower()
# Prefer the original display name from the brand map if available
brand_name = brand_map.get(suffix, suffix.replace('_', ' ').title())
brands.append(brand_name)
except Exception as e:
logger.error(f"Failed to list brands: {e}")
conn.close()
return sorted(list(set(brands)))
def get_products_by_brand(brand: str, limit: Optional[int] = None, offset: int = 0,
category: Optional[str] = None) -> List[Dict[str, Any]]:
"""Fetch products for a specific brand from its table (plain listing, no ranking).
Supports optional pagination (`limit`/`offset`) and an exact-ish category
filter, used by the catalog "browse" endpoints in the FastAPI layer.
"""
conn = _connect()
if not conn:
return []
table_name = _table_name(brand)
products = []
with conn, conn.cursor() as cur:
try:
if not _table_exists(cur, table_name):
logger.warning(f"Table {table_name} does not exist")
return []
sql = f"SELECT * FROM {table_name}"
params: List[Any] = []
if category:
sql += " WHERE category ILIKE %s"
params.append(f"%{category}%")
sql += " ORDER BY updated_at DESC"
if limit is not None:
sql += " LIMIT %s OFFSET %s"
params.extend([limit, offset])
cur.execute(sql, params)
colnames = [desc[0] for desc in cur.description]
for row in cur.fetchall():
products.append(dict(zip(colnames, row)))
except Exception as e:
logger.error(f"Failed to fetch products for {brand}: {e}")
conn.close()
return products
def count_products_by_brand(brand: str, category: Optional[str] = None) -> int:
"""Count products in a brand table (used for pagination totals)."""
conn = _connect()
if not conn:
return 0
table_name = _table_name(brand)
try:
with conn, conn.cursor() as cur:
if not _table_exists(cur, table_name):
return 0
sql = f"SELECT COUNT(*) FROM {table_name}"
params: List[Any] = []
if category:
sql += " WHERE category ILIKE %s"
params.append(f"%{category}%")
cur.execute(sql, params)
row = cur.fetchone()
return int(row[0]) if row else 0
except Exception as e:
logger.error(f"Failed to count products for {brand}: {e}")
return 0
finally:
conn.close()
def get_product_by_image_id(brand: str, image_id: str) -> Optional[Dict[str, Any]]:
"""Fetch a single product row by its unique image_id (used for product-detail lookups)."""
conn = _connect()
if not conn or not image_id:
return None
table_name = _table_name(brand)
try:
with conn, conn.cursor() as cur:
if not _table_exists(cur, table_name):
return None
cur.execute(f"SELECT * FROM {table_name} WHERE image_id = %s LIMIT 1", (image_id,))
row = cur.fetchone()
if not row:
return None
colnames = [desc[0] for desc in cur.description]
return dict(zip(colnames, row))
except Exception as e:
logger.error(f"Failed to fetch product {image_id} for {brand}: {e}")
return None
finally:
conn.close()
def get_products_all_brands(limit: int = 10000, offset: int = 0,
category: Optional[str] = None) -> List[Dict[str, Any]]:
"""Fetch products from all brand tables merged together.
Used by the "All brands" browse view on the frontend to show a
consolidated listing without requiring a full-text search.
"""
all_products: List[Dict[str, Any]] = []
brands = list_available_brands()
remaining = limit
current_offset = offset
for brand in brands:
brand_count = count_products_by_brand(brand, category=category)
if current_offset >= brand_count:
current_offset -= brand_count
continue
brand_limit = min(remaining, brand_count - current_offset)
if brand_limit <= 0:
break
products = get_products_by_brand(brand, limit=brand_limit,
offset=current_offset, category=category)
for p in products:
p["brand"] = brand
all_products.extend(products)
remaining -= len(products)
current_offset = 0
if remaining <= 0:
break
return all_products
def count_products_all_brands(category: Optional[str] = None) -> int:
"""Count the total number of products across all brand tables."""
total = 0
for brand in list_available_brands():
total += count_products_by_brand(brand, category=category)
return total
def list_categories_for_brand(brand: str) -> List[str]:
"""Distinct categories present in a brand table (used to populate filter UI)."""
conn = _connect()
if not conn:
return []
table_name = _table_name(brand)
try:
with conn, conn.cursor() as cur:
if not _table_exists(cur, table_name):
return []
cur.execute(f"SELECT DISTINCT category FROM {table_name} WHERE category IS NOT NULL ORDER BY category")
return [row[0] for row in cur.fetchall() if row[0]]
except Exception as e:
logger.error(f"Failed to list categories for {brand}: {e}")
return []
finally:
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.
If `brand` is given, searches only that brand's table. If omitted,
searches across every known `brand_*` table and merges results by
distance (ascending = more similar), which lets the chat/search
endpoints answer brand-agnostic questions like "show me a low sugar
biscuit under 20 rupees".
Each returned dict includes a `distance` key (pgvector cosine
distance: 0 = identical, 2 = opposite) and a `brand` key identifying
which brand table the row came from, since multi-brand results don't
otherwise carry that information.
`max_distance` can be used to drop weak matches (e.g. 0.6) so the LLM
isn't fed irrelevant context when nothing in the catalog is actually
relevant to the query.
"""
conn = _connect()
if not conn:
return []
embedding_str = "[" + ",".join(map(str, query_embedding)) + "]"
results: List[Dict[str, Any]] = []
try:
with conn, conn.cursor() as cur:
if brand:
tables = [(brand, _table_name(brand))]
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
sql = (
f"SELECT *, embedding <=> %s::vector AS distance FROM {table_name} "
f"WHERE embedding IS NOT NULL"
)
params: List[Any] = [embedding_str]
if category:
sql += " AND category ILIKE %s"
params.append(f"%{category}%")
sql += " ORDER BY distance ASC LIMIT %s"
params.append(fetch_limit)
try:
cur.execute(sql, params)
except Exception as e:
logger.warning(f"Semantic search failed for table {table_name}: {e}")
continue
colnames = [desc[0] for desc in cur.description]
for row in cur.fetchall():
record = dict(zip(colnames, row))
record["brand"] = record.get("brand") or brand_label
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()
# Merge + re-rank across brand tables (no-op when a single brand was searched)
results.sort(key=lambda r: r.get("distance", 9.0))
return results[:top_k]
def text_search(
query: str,
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.
Used when semantic/vector search returns no results. Searches across
brand tables (optionally filtered by `brand` and/or `category`) and
returns results with a fixed `distance` of 1.0 so they sort after
any semantic matches.
"""
conn = _connect()
if not conn:
return []
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:
if brand:
tables = [(brand, _table_name(brand))]
else:
tables = [(name, f"brand_{name}") for name in _list_brand_table_suffixes(cur)]
for brand_label, table_name in tables:
if not _table_exists(cur, table_name):
continue
sql = f"SELECT * FROM {table_name}"
where_clauses: List[str] = []
params: List[Any] = []
where_clauses.append("(title ILIKE %s OR description ILIKE %s OR product_name ILIKE %s)")
params.extend([like_pattern, like_pattern, like_pattern])
if category:
where_clauses.append("category ILIKE %s")
params.append(f"%{category}%")
if where_clauses:
sql += " WHERE " + " AND ".join(where_clauses)
sql += " ORDER BY updated_at DESC LIMIT %s"
params.append(fetch_limit)
try:
cur.execute(sql, params)
except Exception as e:
logger.warning(f"Text search failed for table {table_name}: {e}")
continue
colnames = [desc[0] for desc in cur.description]
for row in cur.fetchall():
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:
conn.close()
return results[:top_k]
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _table_name(brand: str) -> str:
storage_brand = resolve_parent_brand(brand)
return f"brand_{_sanitize_name(storage_brand)}"
def _table_exists(cur, table_name: str) -> bool:
cur.execute(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
)
""",
(table_name,),
)
row = cur.fetchone()
return bool(row and row[0])
def _list_brand_table_suffixes(cur) -> List[str]:
"""Return brand-table suffixes (e.g. 'parle' from 'brand_parle') for every brand table."""
cur.execute(
"""
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_name LIKE 'brand_%'
"""
)
return [row[0][len("brand_"):] for row in cur.fetchall()]