updates on the backend
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
31
app/api/background.py
Normal file
31
app/api/background.py
Normal 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
59
app/api/job_store.py
Normal 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()
|
||||
61
app/api/nutrition_job_store.py
Normal file
61
app/api/nutrition_job_store.py
Normal 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()
|
||||
130
app/api/nutrition_schemas.py
Normal file
130
app/api/nutrition_schemas.py
Normal 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
|
||||
0
app/api/routers/__init__.py
Normal file
0
app/api/routers/__init__.py
Normal file
163
app/api/routers/admin_train.py
Normal file
163
app/api/routers/admin_train.py
Normal 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
|
||||
}
|
||||
46
app/api/routers/analytics.py
Normal file
46
app/api/routers/analytics.py
Normal 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
120
app/api/routers/auth.py
Normal 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
152
app/api/routers/brands.py
Normal 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)
|
||||
54
app/api/routers/catalog.py
Normal file
54
app/api/routers/catalog.py
Normal 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
33
app/api/routers/chat.py
Normal 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],
|
||||
)
|
||||
35
app/api/routers/discounts.py
Normal file
35
app/api/routers/discounts.py
Normal 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
47
app/api/routers/health.py
Normal 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,
|
||||
)
|
||||
195
app/api/routers/nutrition.py
Normal file
195
app/api/routers/nutrition.py
Normal 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]
|
||||
102
app/api/routers/nutrition_admin.py
Normal file
102
app/api/routers/nutrition_admin.py
Normal 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()}
|
||||
20
app/api/routers/recommendations.py
Normal file
20
app/api/routers/recommendations.py
Normal 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
29
app/api/routers/search.py
Normal 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],
|
||||
)
|
||||
67
app/api/routers/store_admin.py
Normal file
67
app/api/routers/store_admin.py
Normal 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
77
app/api/routers/stores.py
Normal 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
90
app/api/routers/system.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.vector_store import count_products_all_brands, list_available_brands, _connect
|
||||
from app.services.store_db import list_stores
|
||||
from app.services.ollama_service import _ensure_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["system"])
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[3]
|
||||
FRONTEND_DIST = BASE_DIR.parent / "frontend" / "dist"
|
||||
|
||||
|
||||
class SystemStatusOut(BaseModel):
|
||||
status: str
|
||||
database_connected: bool
|
||||
total_products: int
|
||||
available_brands: list[str]
|
||||
total_stores: int
|
||||
ollama_connected: bool
|
||||
frontend_dist_exists: bool
|
||||
|
||||
|
||||
def _run_background_auto_seed():
|
||||
"""Background task to run initial seeding asynchronously if DB is empty."""
|
||||
try:
|
||||
if count_products_all_brands() == 0:
|
||||
logger.info("⚡ Background Auto-Init: Database empty. Running initial sample seed...")
|
||||
cmd_seed = [os.sys.executable, str(BASE_DIR / "scripts" / "seed_sample_data.py"), "--skip-if-seeded"]
|
||||
subprocess.run(cmd_seed, check=False)
|
||||
|
||||
logger.info("⚡ Background Auto-Init: Provisioning store intelligence...")
|
||||
cmd_store = [os.sys.executable, str(BASE_DIR / "scripts" / "seed_store_intelligence.py"), "--skip-if-seeded"]
|
||||
subprocess.run(cmd_store, check=False)
|
||||
logger.info("✅ Background Auto-Init complete!")
|
||||
except Exception as e:
|
||||
logger.error("Background Auto-Init error: %s", e)
|
||||
|
||||
|
||||
@router.get("/system/status", response_model=SystemStatusOut)
|
||||
def get_system_status() -> SystemStatusOut:
|
||||
"""Return unified status of database, vector store, stores, and frontend build."""
|
||||
db_connected = False
|
||||
products_count = 0
|
||||
brands = []
|
||||
stores_count = 0
|
||||
|
||||
try:
|
||||
conn = _connect()
|
||||
if conn:
|
||||
db_connected = True
|
||||
conn.close()
|
||||
products_count = count_products_all_brands()
|
||||
brands = list_available_brands()
|
||||
stores_count = len(list_stores())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ollama_ok = _ensure_client()
|
||||
dist_ok = FRONTEND_DIST.exists() and (FRONTEND_DIST / "index.html").exists()
|
||||
|
||||
return SystemStatusOut(
|
||||
status="ok" if db_connected else "degraded",
|
||||
database_connected=db_connected,
|
||||
total_products=products_count,
|
||||
available_brands=brands,
|
||||
total_stores=stores_count,
|
||||
ollama_connected=ollama_ok,
|
||||
frontend_dist_exists=dist_ok,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/system/init")
|
||||
def initialize_system(background_tasks: BackgroundTasks) -> Dict[str, Any]:
|
||||
"""Trigger background auto-initialization of sample catalog and store data."""
|
||||
background_tasks.add_task(_run_background_auto_seed)
|
||||
return {
|
||||
"status": "started",
|
||||
"message": "Background initialization triggered. Check /api/system/status for progress.",
|
||||
}
|
||||
31
app/api/routers/trending.py
Normal file
31
app/api/routers/trending.py
Normal 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
447
app/api/routers/upload.py
Normal file
@@ -0,0 +1,447 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import uuid
|
||||
import logging
|
||||
import pandas as pd
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile, Response
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from app.services.vector_store import _connect
|
||||
from app.services import store_db, nutrition_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/upload", tags=["upload"])
|
||||
|
||||
|
||||
def _normalize_col(col: str) -> str:
|
||||
"""Normalize dataframe column names (lower, strip, replace spaces/hyphens with underscore)."""
|
||||
return str(col).strip().lower().replace(' ', '_').replace('-', '_')
|
||||
|
||||
|
||||
def read_df_from_upload(filename: str, contents: bytes) -> pd.DataFrame:
|
||||
"""Parse CSV or Excel (xlsx/xls) upload file into a pandas DataFrame."""
|
||||
fn_lower = filename.lower()
|
||||
if fn_lower.endswith('.xlsx') or fn_lower.endswith('.xls'):
|
||||
df = pd.read_excel(io.BytesIO(contents))
|
||||
elif fn_lower.endswith('.tsv'):
|
||||
df = pd.read_csv(io.BytesIO(contents), sep='\t')
|
||||
else:
|
||||
try:
|
||||
df = pd.read_csv(io.BytesIO(contents))
|
||||
except Exception:
|
||||
df = pd.read_csv(io.BytesIO(contents), sep=None, engine='python')
|
||||
|
||||
# Rename columns to normalized format
|
||||
df.columns = [_normalize_col(c) for c in df.columns]
|
||||
return df
|
||||
|
||||
|
||||
def _get_str(row: dict, keys: List[str], default: str = "") -> str:
|
||||
for k in keys:
|
||||
if k in row and pd.notna(row[k]):
|
||||
val = str(row[k]).strip()
|
||||
if val:
|
||||
return val
|
||||
return default
|
||||
|
||||
|
||||
def _get_float(row: dict, keys: List[str], default: float = 0.0) -> float:
|
||||
for k in keys:
|
||||
if k in row and pd.notna(row[k]):
|
||||
try:
|
||||
return float(row[k])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def _get_int(row: dict, keys: List[str], default: int = 0) -> int:
|
||||
for k in keys:
|
||||
if k in row and pd.notna(row[k]):
|
||||
try:
|
||||
return int(float(row[k]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stores Inventory Excel / CSV Upload
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/stores")
|
||||
@router.post("/stores/upload")
|
||||
async def upload_stores_file(file: UploadFile = File(...)) -> Dict[str, Any]:
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No file uploaded")
|
||||
|
||||
contents = await file.read()
|
||||
try:
|
||||
df = read_df_from_upload(file.filename, contents)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Could not parse Excel/CSV file: {e}")
|
||||
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=400, detail="Uploaded file contains no data rows")
|
||||
|
||||
conn = _connect()
|
||||
if not conn:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
imported_count = 0
|
||||
stores_created = set()
|
||||
|
||||
try:
|
||||
with conn, conn.cursor() as cur:
|
||||
# Ensure tables exist
|
||||
store_db.ensure_store_intelligence_schema()
|
||||
|
||||
for _, r in df.iterrows():
|
||||
row = r.to_dict()
|
||||
store_id = _get_str(row, ['store_id', 'store'], 'store_mumbai_1')
|
||||
brand = _get_str(row, ['brand', 'brand_name'], 'amul').lower()
|
||||
product_name = _get_str(row, ['product_name', 'title', 'name', 'item'], 'Product Item')
|
||||
image_id = _get_str(row, ['image_id', 'sku', 'product_sku', 'item_id'], '')
|
||||
if not image_id:
|
||||
image_id = f"{brand}_{product_name.lower().replace(' ', '_')}"
|
||||
|
||||
category = _get_str(row, ['category', 'cat'], 'Dairy')
|
||||
avail_stock = _get_int(row, ['available_stock', 'stock', 'qty', 'quantity'], 50)
|
||||
reserved_stock = _get_int(row, ['reserved_stock', 'reserved'], 0)
|
||||
reorder_lvl = _get_int(row, ['reorder_level', 'reorder'], 15)
|
||||
safety_stk = _get_int(row, ['safety_stock', 'safety'], 10)
|
||||
|
||||
mrp = _get_float(row, ['mrp', 'price'], 100.0)
|
||||
cost_price = _get_float(row, ['cost_price', 'cost'], 70.0)
|
||||
selling_price = _get_float(row, ['selling_price', 'sell_price'], mrp * 0.9 if mrp else 90.0)
|
||||
|
||||
# 1. Ensure store exists
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO stores (store_id, store_name, city, tier, footfall_index)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (store_id) DO NOTHING
|
||||
""",
|
||||
(store_id, store_id.replace('_', ' ').title(), 'Mumbai', 'standard', 25.0)
|
||||
)
|
||||
stores_created.add(store_id)
|
||||
|
||||
# 2. Upsert store_inventory
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO store_inventory
|
||||
(store_id, brand, image_id, title, category, available_stock, reserved_stock, reorder_level, safety_stock)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (store_id, brand, image_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
category = EXCLUDED.category,
|
||||
available_stock = EXCLUDED.available_stock,
|
||||
reserved_stock = EXCLUDED.reserved_stock,
|
||||
reorder_level = EXCLUDED.reorder_level,
|
||||
safety_stock = EXCLUDED.safety_stock,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(store_id, brand, image_id, product_name, category, avail_stock, reserved_stock, reorder_lvl, safety_stk)
|
||||
)
|
||||
|
||||
# 3. Upsert store_prices
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO store_prices (store_id, brand, image_id, mrp, cost_price, selling_price)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (store_id, brand, image_id) DO UPDATE SET
|
||||
mrp = EXCLUDED.mrp,
|
||||
cost_price = EXCLUDED.cost_price,
|
||||
selling_price = EXCLUDED.selling_price,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(store_id, brand, image_id, mrp, cost_price, selling_price)
|
||||
)
|
||||
imported_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Stores upload failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Database import failed: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": file.filename,
|
||||
"rows_total": len(df),
|
||||
"rows_imported": imported_count,
|
||||
"stores_affected": list(stores_created),
|
||||
"message": f"Successfully imported {imported_count} store inventory items across {len(stores_created)} store(s)."
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sales / Analytics Excel / CSV Upload
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/analytics")
|
||||
@router.post("/analytics/upload")
|
||||
async def upload_analytics_file(file: UploadFile = File(...)) -> Dict[str, Any]:
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No file uploaded")
|
||||
|
||||
contents = await file.read()
|
||||
try:
|
||||
df = read_df_from_upload(file.filename, contents)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Could not parse Excel/CSV file: {e}")
|
||||
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=400, detail="Uploaded file contains no data rows")
|
||||
|
||||
conn = _connect()
|
||||
if not conn:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
imported_orders = 0
|
||||
total_revenue = 0.0
|
||||
|
||||
try:
|
||||
with conn, conn.cursor() as cur:
|
||||
store_db.ensure_store_intelligence_schema()
|
||||
|
||||
for _, r in df.iterrows():
|
||||
row = r.to_dict()
|
||||
store_id = _get_str(row, ['store_id', 'store'], 'store_mumbai_1')
|
||||
brand = _get_str(row, ['brand', 'brand_name'], 'amul').lower()
|
||||
image_id = _get_str(row, ['image_id', 'sku', 'product_sku'], '')
|
||||
product_name = _get_str(row, ['product_name', 'title', 'item'], 'Analytics Item')
|
||||
if not image_id:
|
||||
image_id = f"{brand}_{product_name.lower().replace(' ', '_')}"
|
||||
|
||||
order_id = _get_str(row, ['order_id', 'transaction_id'], f"ord_up_{uuid.uuid4().hex[:8]}")
|
||||
customer_id = _get_str(row, ['customer_id', 'user_id', 'customer'], 'cust_imported')
|
||||
|
||||
raw_date = _get_str(row, ['order_date', 'date', 'timestamp'], '')
|
||||
order_date = datetime.now()
|
||||
if raw_date:
|
||||
try:
|
||||
order_date = pd.to_datetime(raw_date).to_pydatetime()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
qty = _get_int(row, ['quantity', 'units_sold', 'qty', 'count'], 1)
|
||||
unit_price = _get_float(row, ['unit_price', 'selling_price', 'price'], 100.0)
|
||||
tot_price = _get_float(row, ['total_price', 'revenue', 'total'], qty * unit_price)
|
||||
|
||||
# Ensure store exists
|
||||
cur.execute(
|
||||
"INSERT INTO stores (store_id, store_name, city, tier, footfall_index) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (store_id) DO NOTHING",
|
||||
(store_id, store_id.replace('_', ' ').title(), 'Mumbai', 'standard', 25.0)
|
||||
)
|
||||
|
||||
# Insert order header
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO orders (order_id, customer_id, store_id, order_date, payment_method, order_value, delivery_status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (order_id) DO UPDATE SET order_value = EXCLUDED.order_value
|
||||
""",
|
||||
(order_id, customer_id, store_id, order_date, 'upi', tot_price, 'delivered')
|
||||
)
|
||||
|
||||
# Insert order item
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO order_items (order_id, brand, image_id, quantity, unit_price, total_price)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(order_id, brand, image_id, qty, unit_price, tot_price)
|
||||
)
|
||||
|
||||
imported_orders += 1
|
||||
total_revenue += tot_price
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Analytics upload failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Database import failed: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": file.filename,
|
||||
"rows_total": len(df),
|
||||
"rows_imported": imported_orders,
|
||||
"total_revenue": round(total_revenue, 2),
|
||||
"message": f"Successfully imported {imported_orders} sales transactions (Total Revenue: ₹{total_revenue:,.2f})."
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nutrition Intelligence Excel / CSV Upload
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/nutrition")
|
||||
@router.post("/nutrition/upload")
|
||||
async def upload_nutrition_file(file: UploadFile = File(...)) -> Dict[str, Any]:
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No file uploaded")
|
||||
|
||||
contents = await file.read()
|
||||
try:
|
||||
df = read_df_from_upload(file.filename, contents)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Could not parse Excel/CSV file: {e}")
|
||||
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=400, detail="Uploaded file contains no data rows")
|
||||
|
||||
conn = _connect()
|
||||
if not conn:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
imported_count = 0
|
||||
|
||||
try:
|
||||
with conn, conn.cursor() as cur:
|
||||
nutrition_db.ensure_nutrition_schema()
|
||||
|
||||
for _, r in df.iterrows():
|
||||
row = r.to_dict()
|
||||
brand = _get_str(row, ['brand', 'brand_name'], 'amul').lower()
|
||||
product_name = _get_str(row, ['product_name', 'title', 'item', 'name'], 'Nutrition Item')
|
||||
image_id = _get_str(row, ['image_id', 'sku', 'id'], '')
|
||||
if not image_id:
|
||||
image_id = f"{brand}_{product_name.lower().replace(' ', '_')}"
|
||||
|
||||
category = _get_str(row, ['category', 'cat'], 'Food')
|
||||
|
||||
calories = _get_float(row, ['calories', 'calories_kcal', 'energy'], 150.0)
|
||||
protein = _get_float(row, ['protein', 'protein_g'], 5.0)
|
||||
carbs = _get_float(row, ['carbohydrates', 'carbs', 'carbohydrates_g'], 20.0)
|
||||
sugar = _get_float(row, ['sugar', 'total_sugar_g', 'sugars'], 4.0)
|
||||
fiber = _get_float(row, ['fiber', 'dietary_fiber_g'], 2.0)
|
||||
fat = _get_float(row, ['fat', 'total_fat_g'], 6.0)
|
||||
sodium = _get_float(row, ['sodium', 'sodium_mg'], 120.0)
|
||||
calcium = _get_float(row, ['calcium', 'calcium_mg'], 80.0)
|
||||
iron = _get_float(row, ['iron', 'iron_mg'], 1.5)
|
||||
vitamin_c = _get_float(row, ['vitamin_c', 'vitamin_c_mg'], 5.0)
|
||||
|
||||
health_score = _get_float(row, ['health_score', 'nutrition_score', 'score'], 78.0)
|
||||
diet_tags_raw = _get_str(row, ['diet_tags', 'tags', 'diet'], 'High Protein, Gluten Free')
|
||||
allergens_raw = _get_str(row, ['allergens', 'allergen'], 'None')
|
||||
|
||||
diet_tags = [t.strip() for t in diet_tags_raw.split(',') if t.strip()]
|
||||
allergens = [a.strip() for a in allergens_raw.split(',') if a.strip()]
|
||||
|
||||
# 1. Upsert nutrition_facts
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO nutrition_facts
|
||||
(brand, image_id, product_name, category, data_status, data_source,
|
||||
calories_kcal, protein_g, carbohydrates_g, total_sugar_g, dietary_fiber_g,
|
||||
total_fat_g, sodium_mg, calcium_mg, iron_mg, vitamin_c_mg)
|
||||
VALUES (%s, %s, %s, %s, 'verified', 'excel_upload', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (brand, image_id) DO UPDATE SET
|
||||
product_name = EXCLUDED.product_name,
|
||||
category = EXCLUDED.category,
|
||||
data_status = 'verified',
|
||||
calories_kcal = EXCLUDED.calories_kcal,
|
||||
protein_g = EXCLUDED.protein_g,
|
||||
carbohydrates_g = EXCLUDED.carbohydrates_g,
|
||||
total_sugar_g = EXCLUDED.total_sugar_g,
|
||||
dietary_fiber_g = EXCLUDED.dietary_fiber_g,
|
||||
total_fat_g = EXCLUDED.total_fat_g,
|
||||
sodium_mg = EXCLUDED.sodium_mg,
|
||||
calcium_mg = EXCLUDED.calcium_mg,
|
||||
iron_mg = EXCLUDED.iron_mg,
|
||||
vitamin_c_mg = EXCLUDED.vitamin_c_mg
|
||||
""",
|
||||
(brand, image_id, product_name, category, calories, protein, carbs, sugar, fiber, fat, sodium, calcium, iron, vitamin_c)
|
||||
)
|
||||
|
||||
# 2. Upsert nutrition_insights
|
||||
insights_json = json.dumps({
|
||||
"brand": brand,
|
||||
"image_id": image_id,
|
||||
"data_status": "verified",
|
||||
"nutrition_score": health_score,
|
||||
"health_score": health_score,
|
||||
"positive_insights": [f"Contains {protein}g protein per 100g", f"Provides {fiber}g dietary fiber"],
|
||||
"nutritional_cautions": [f"{sugar}g sugar per 100g"],
|
||||
"diet_tags": diet_tags,
|
||||
"allergens": allergens
|
||||
})
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO nutrition_insights
|
||||
(brand, image_id, data_status, nutrition_score, health_score, score_breakdown,
|
||||
positive_insights, nutritional_cautions, diet_tags, allergens)
|
||||
VALUES (%s, %s, 'verified', %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (brand, image_id) DO UPDATE SET
|
||||
data_status = 'verified',
|
||||
nutrition_score = EXCLUDED.nutrition_score,
|
||||
health_score = EXCLUDED.health_score,
|
||||
positive_insights = EXCLUDED.positive_insights,
|
||||
nutritional_cautions = EXCLUDED.nutritional_cautions,
|
||||
diet_tags = EXCLUDED.diet_tags,
|
||||
allergens = EXCLUDED.allergens
|
||||
""",
|
||||
(brand, image_id, health_score, health_score, json.dumps({"protein": 85, "fiber": 80}),
|
||||
[f"Contains {protein}g protein per 100g"], [f"{sugar}g sugar per 100g"], diet_tags, allergens)
|
||||
)
|
||||
|
||||
imported_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Nutrition upload failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Database import failed: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": file.filename,
|
||||
"rows_total": len(df),
|
||||
"rows_imported": imported_count,
|
||||
"message": f"Successfully imported {imported_count} nutritional intelligence items."
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template Downloads
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/template/{tab_type}")
|
||||
def get_sample_template(tab_type: str) -> Response:
|
||||
tab_type = tab_type.lower()
|
||||
|
||||
if tab_type == 'stores':
|
||||
content = (
|
||||
"store_id,brand,image_id,product_name,category,available_stock,reserved_stock,mrp,cost_price,selling_price,reorder_level,safety_stock\n"
|
||||
"store_mumbai_1,amul,amul_amul_butter_500ml,Amul Butter 500ml,Dairy,120,5,250.00,200.00,235.00,20,10\n"
|
||||
"store_mumbai_1,amul,amul_amul_ghee_1l,Amul Ghee 1L,Dairy,85,2,650.00,520.00,610.00,15,5\n"
|
||||
"store_delhi_2,nestle,nestle_everyday_1kg,Everyday Milk Powder 1kg,Dairy,45,0,420.00,340.00,399.00,10,5\n"
|
||||
)
|
||||
filename = "sample_stores_inventory_template.csv"
|
||||
elif tab_type == 'analytics':
|
||||
content = (
|
||||
"order_id,store_id,brand,image_id,product_name,order_date,customer_id,quantity,unit_price,total_price\n"
|
||||
"ORD_9001,store_mumbai_1,amul,amul_amul_butter_500ml,Amul Butter 500ml,2026-08-01 10:30:00,cust_101,2,235.00,470.00\n"
|
||||
"ORD_9002,store_mumbai_1,amul,amul_amul_ghee_1l,Amul Ghee 1L,2026-08-01 11:15:00,cust_102,1,610.00,610.00\n"
|
||||
"ORD_9003,store_delhi_2,nestle,nestle_everyday_1kg,Everyday Milk Powder 1kg,2026-08-02 14:20:00,cust_103,3,399.00,1197.00\n"
|
||||
)
|
||||
filename = "sample_analytics_sales_template.csv"
|
||||
elif tab_type == 'nutrition':
|
||||
content = (
|
||||
"brand,image_id,product_name,category,calories_kcal,protein_g,carbohydrates_g,total_sugar_g,dietary_fiber_g,total_fat_g,sodium_mg,health_score,diet_tags,allergens\n"
|
||||
"amul,amul_amul_butter_500ml,Amul Butter 500ml,Dairy,717,0.8,0.1,0.0,0.0,81.0,650,75,Vegetarian,Dairy\n"
|
||||
"amul,amul_amul_ghee_1l,Amul Ghee 1L,Dairy,898,0.0,0.0,0.0,0.0,99.8,0,82,Vegetarian,Keto Friendly\n"
|
||||
"nestle,nestle_everyday_1kg,Everyday Milk Powder 1kg,Dairy,496,25.5,38.0,38.0,0.0,27.0,350,88,High Protein,Dairy\n"
|
||||
)
|
||||
filename = "sample_nutrition_intelligence_template.csv"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown template type '{tab_type}'. Use stores, analytics, or nutrition.")
|
||||
|
||||
return PlainTextResponse(
|
||||
content=content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
349
app/api/routers/user_products.py
Normal file
349
app/api/routers/user_products.py
Normal 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
152
app/api/schemas.py
Normal 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
|
||||
56
app/api/store_job_store.py
Normal file
56
app/api/store_job_store.py
Normal 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
95
app/api/store_schemas.py
Normal 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
0
app/core/__init__.py
Normal file
967
app/core/catalog_engine.py
Normal file
967
app/core/catalog_engine.py
Normal 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
44
app/core/ingestion.py
Normal 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
|
||||
66
app/data/seed_catalogs/brand_catalog_lion_dates.json
Normal file
66
app/data/seed_catalogs/brand_catalog_lion_dates.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
0
app/infrastructure/__init__.py
Normal file
0
app/infrastructure/__init__.py
Normal file
149
app/infrastructure/settings.py
Normal file
149
app/infrastructure/settings.py
Normal 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
|
||||
41
app/intelligence/__init__.py
Normal file
41
app/intelligence/__init__.py
Normal 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.
|
||||
"""
|
||||
215
app/intelligence/analytics.py
Normal file
215
app/intelligence/analytics.py
Normal 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()
|
||||
],
|
||||
}
|
||||
BIN
app/intelligence/artifacts/demand_forecast_model.joblib
Normal file
BIN
app/intelligence/artifacts/demand_forecast_model.joblib
Normal file
Binary file not shown.
BIN
app/intelligence/artifacts/discount_model.joblib
Normal file
BIN
app/intelligence/artifacts/discount_model.joblib
Normal file
Binary file not shown.
BIN
app/intelligence/artifacts/nutrition_clustering.joblib
Normal file
BIN
app/intelligence/artifacts/nutrition_clustering.joblib
Normal file
Binary file not shown.
BIN
app/intelligence/artifacts/nutrition_similarity.joblib
Normal file
BIN
app/intelligence/artifacts/nutrition_similarity.joblib
Normal file
Binary file not shown.
BIN
app/intelligence/artifacts/popularity_model.joblib
Normal file
BIN
app/intelligence/artifacts/popularity_model.joblib
Normal file
Binary file not shown.
BIN
app/intelligence/artifacts/purchase_propensity_model.joblib
Normal file
BIN
app/intelligence/artifacts/purchase_propensity_model.joblib
Normal file
Binary file not shown.
BIN
app/intelligence/artifacts/store_performance_model.joblib
Normal file
BIN
app/intelligence/artifacts/store_performance_model.joblib
Normal file
Binary file not shown.
BIN
app/intelligence/artifacts/trending_model.joblib
Normal file
BIN
app/intelligence/artifacts/trending_model.joblib
Normal file
Binary file not shown.
148
app/intelligence/discount_model.py
Normal file
148
app/intelligence/discount_model.py
Normal 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()
|
||||
49
app/intelligence/engagement_simulation.py
Normal file
49
app/intelligence/engagement_simulation.py
Normal 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)
|
||||
158
app/intelligence/features.py
Normal file
158
app/intelligence/features.py
Normal 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
|
||||
140
app/intelligence/forecasting.py
Normal file
140
app/intelligence/forecasting.py
Normal 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()
|
||||
61
app/intelligence/model_utils.py
Normal file
61
app/intelligence/model_utils.py
Normal 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
|
||||
115
app/intelligence/nutrition_clustering.py
Normal file
115
app/intelligence/nutrition_clustering.py
Normal 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]
|
||||
143
app/intelligence/nutrition_recommendation.py
Normal file
143
app/intelligence/nutrition_recommendation.py
Normal 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,
|
||||
}
|
||||
113
app/intelligence/nutrition_similarity.py
Normal file
113
app/intelligence/nutrition_similarity.py
Normal 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
|
||||
186
app/intelligence/order_simulation.py
Normal file
186
app/intelligence/order_simulation.py
Normal 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()),
|
||||
],
|
||||
}
|
||||
82
app/intelligence/popularity_model.py
Normal file
82
app/intelligence/popularity_model.py
Normal 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()
|
||||
99
app/intelligence/purchase_propensity_model.py
Normal file
99
app/intelligence/purchase_propensity_model.py
Normal 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()
|
||||
151
app/intelligence/recommendation_engine.py
Normal file
151
app/intelligence/recommendation_engine.py
Normal 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]
|
||||
]
|
||||
126
app/intelligence/store_performance_model.py
Normal file
126
app/intelligence/store_performance_model.py
Normal 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()
|
||||
217
app/intelligence/store_provisioning.py
Normal file
217
app/intelligence/store_provisioning.py
Normal 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
|
||||
129
app/intelligence/synthetic_labels.py
Normal file
129
app/intelligence/synthetic_labels.py
Normal 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)
|
||||
167
app/intelligence/trending_model.py
Normal file
167
app/intelligence/trending_model.py
Normal 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
107
app/main.py
Normal 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
0
app/services/__init__.py
Normal file
72
app/services/analytics_service.py
Normal file
72
app/services/analytics_service.py
Normal 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)
|
||||
293
app/services/brand_registry.py
Normal file
293
app/services/brand_registry.py
Normal 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
|
||||
222
app/services/category_registry.py
Normal file
222
app/services/category_registry.py
Normal 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"
|
||||
93
app/services/discount_service.py
Normal file
93
app/services/discount_service.py
Normal 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
|
||||
52
app/services/embeddings_service.py
Normal file
52
app/services/embeddings_service.py
Normal 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()
|
||||
538
app/services/image_search.py
Normal file
538
app/services/image_search.py
Normal 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
|
||||
180
app/services/ml_training_service.py
Normal file
180
app/services/ml_training_service.py
Normal 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
|
||||
61
app/services/nutrition_alternatives_service.py
Normal file
61
app/services/nutrition_alternatives_service.py
Normal 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
|
||||
46
app/services/nutrition_analytics_service.py
Normal file
46
app/services/nutrition_analytics_service.py
Normal 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(),
|
||||
}
|
||||
356
app/services/nutrition_data_service.py
Normal file
356
app/services/nutrition_data_service.py
Normal 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
|
||||
702
app/services/nutrition_db.py
Normal file
702
app/services/nutrition_db.py
Normal 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()
|
||||
164
app/services/nutrition_enrichment_service.py
Normal file
164
app/services/nutrition_enrichment_service.py
Normal 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}
|
||||
52
app/services/nutrition_narrative_service.py
Normal file
52
app/services/nutrition_narrative_service.py
Normal 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."
|
||||
347
app/services/nutrition_scoring.py
Normal file
347
app/services/nutrition_scoring.py
Normal 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
|
||||
375
app/services/ollama_service.py
Normal file
375
app/services/ollama_service.py
Normal 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
|
||||
|
||||
|
||||
129
app/services/playwright_image_fallback.py
Normal file
129
app/services/playwright_image_fallback.py
Normal 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()
|
||||
355
app/services/price_estimator.py
Normal file
355
app/services/price_estimator.py
Normal 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]
|
||||
202
app/services/query_intent.py
Normal file
202
app/services/query_intent.py
Normal 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
351
app/services/rag_service.py
Normal 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,
|
||||
)
|
||||
110
app/services/recommendation_service.py
Normal file
110
app/services/recommendation_service.py
Normal 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
301
app/services/s3_service.py
Normal 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
877
app/services/store_db.py
Normal 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()
|
||||
76
app/services/store_seed_service.py
Normal file
76
app/services/store_seed_service.py
Normal 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"],
|
||||
}
|
||||
87
app/services/trending_service.py
Normal file
87
app/services/trending_service.py
Normal 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]
|
||||
800
app/services/vector_store.py
Normal file
800
app/services/vector_store.py
Normal 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()]
|
||||
Reference in New Issue
Block a user