updates on the backend

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

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

View File

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

View File

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

View File

@@ -0,0 +1,222 @@
"""
Single source of truth for the product-category taxonomy used by this
catalog: what keywords identify each category, and what "generic noun"
should be used to describe a product of that category.
Why this file exists
---------------------
Two different parts of the system used to guess categories independently:
1. `catalog_engine.py` had its own small keyword->category heuristic used
when the LLM left a product `Uncategorized`.
2. The RAG chat/search path had NO category awareness at all - a query
like "recommend biscuits with low sugar" was answered purely by
embedding similarity, with no guardrail that would stop a `Snacks`
product (e.g. "ITC Bingo Korean Style") from being recommended for a
biscuit query just because its (LLM-generated) description happened
to use the word "biscuit" loosely.
Centralizing the keyword map here means both sides agree on the same
taxonomy, and it becomes the basis for:
- `detect_category_from_text()` - used by the RAG query pipeline to
figure out which category (if any) the user is actually asking
about, so retrieval can be scoped to that category.
- `category_matches()` - a normalized equality check used
as a safety net after retrieval, so a product that slipped through
with the wrong category (e.g. stale/legacy embeddings) never
reaches the LLM or the user.
- `sanitize_category_language()` - used at ingestion time to strip
cross-category noun leakage out of LLM-generated descriptions
(e.g. rewriting "a crispy, savory biscuit" to "a crispy, savory
snack" for a product actually categorized as `Snacks`), which is
the root cause of the embedding pollution described above.
"""
from __future__ import annotations
import re
from typing import Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# Canonical taxonomy
# ---------------------------------------------------------------------------
# Ordered by priority: when a query or description matches keywords from
# more than one category, the category that appears EARLIER in this dict
# wins. Narrower / more specific product-type categories are listed before
# broader or modifier-like ones (e.g. "Biscuits & Cookies" before
# "Chocolates", so "chocolate biscuits" resolves to biscuits, not
# chocolates).
#
# `keywords` - phrases that identify this category (checked as whole
# words/phrases, case-insensitive, longest match wins
# within a category).
# `generic_term` - the neutral singular noun used to describe a product of
# this category when sanitizing cross-category language
# out of a generated description.
CATEGORY_REGISTRY: List[Dict[str, object]] = [
{"category": "Biscuits & Cookies", "keywords": ["biscuits", "biscuit", "biscit", "biskut", "cookies", "cookie"], "generic_term": "biscuit"},
{"category": "Rusk", "keywords": ["rusks", "rusk"], "generic_term": "rusk"},
{"category": "Crackers", "keywords": ["crackers", "cracker", "saltine"], "generic_term": "cracker"},
{"category": "Cakes & Muffins", "keywords": ["cakes", "cake", "muffins", "muffin"], "generic_term": "bakery item"},
{"category": "Bakery & Breads", "keywords": ["bread", "buns", "bun", "pav"], "generic_term": "bakery product"},
{"category": "Noodles & Instant Food", "keywords": ["noodles", "noodle", "instant food", "vermicelli", "pasta", "maggi"], "generic_term": "instant food product"},
{"category": "Candy & Confectionery", "keywords": ["candy", "candies", "toffee", "toffees", "lollipop", "lollipops", "confectionery", "mints", "chewing gum"], "generic_term": "candy"},
{"category": "Snacks", "keywords": ["snacks", "snack", "chips", "namkeen", "wafers", "wafer", "kurkure", "lays"], "generic_term": "snack"},
{"category": "Chocolates", "keywords": ["chocolates", "chocolate", "chocate", "choclate", "cocoa", "cadbury chocolate", "dairy milk"], "generic_term": "chocolate"},
{"category": "Cooking Oils", "keywords": ["cooking oil", "edible oil", "sunflower oil", "mustard oil", "vanaspati", "refined oil", "oil", "oils"], "generic_term": "cooking oil"},
{"category": "Atta & Staples", "keywords": ["atta", "wheat flour", "flour", "rice", "dal", "pulses", "staples", "suji", "maida"], "generic_term": "staple product"},
{"category": "Dairy", "keywords": ["milk", "dairy", "cheese", "paneer", "panner", "paner", "paneerr", "curd", "yogurt", "butter", "ghee", "dahi"], "generic_term": "dairy product"},
{"category": "Oral Care", "keywords": ["toothpaste", "toothbrush", "mouthwash", "paste"], "generic_term": "oral care product"},
{"category": "Hair Care", "keywords": ["shampoo", "shampooo", "conditioner", "hair oil"], "generic_term": "hair care product"},
{"category": "Bath Soap", "keywords": ["bath soap", "soap bar", "soap", "soaps"], "generic_term": "soap"},
{"category": "Skin & Bath Care", "keywords": ["face wash", "body lotion", "skin cream", "moisturizer", "body wash", "cream", "lotion"], "generic_term": "skin care product"},
{"category": "Household Cleaning", "keywords": ["detergent", "laundry", "dishwash", "floor cleaner", "handwash", "cleaner"], "generic_term": "cleaning product"},
{"category": "Fragrance & Deodorants", "keywords": ["deodorant", "deo spray", "perfume", "fragrance", "body spray", "deo"], "generic_term": "fragrance product"},
{"category": "Household - Agarbatti", "keywords": ["agarbatti", "incense sticks", "incense stick"], "generic_term": "agarbatti"},
{"category": "Household - Lamp Oil", "keywords": ["lamp oil"], "generic_term": "lamp oil"},
{"category": "Health Care - Antiseptic", "keywords": ["antiseptic", "disinfectant liquid", "sanitizer"], "generic_term": "antiseptic product"},
]
# All known canonical category names, in priority order.
ALL_CATEGORIES: List[str] = [entry["category"] for entry in CATEGORY_REGISTRY]
def _normalize(text: Optional[str]) -> str:
"""Lowercase and strip everything except letters/digits/spaces, so
'Biscuits & Cookies' and 'biscuits and cookies' compare equal."""
if not text:
return ""
text = text.lower().replace("&", " and ")
text = re.sub(r"[^a-z0-9\s]", " ", text)
return re.sub(r"\s+", " ", text).strip()
QUERY_STOP_WORDS = {
"price", "prices", "cost", "costs", "which", "where", "what", "show",
"find", "have", "product", "products", "item", "items", "brand", "brands",
"under", "below", "less", "more", "many", "total", "count", "please", "today",
"recommend", "suggest", "options", "option", "rate", "rates",
}
def _find_matches(text: str) -> List[Tuple[str, str, int]]:
"""Return (category, matched_keyword, keyword_length) for every keyword
found as a whole word/phrase in `text` (case-insensitive)."""
if not text:
return []
lower = text.lower()
matches: List[Tuple[str, str, int]] = []
for entry in CATEGORY_REGISTRY:
category = entry["category"]
for kw in entry["keywords"]:
pattern = r"\b" + re.escape(kw) + r"\b"
if re.search(pattern, lower):
matches.append((category, kw, len(kw)))
# Fuzzy matching fallback if exact word search found nothing
if not matches:
import difflib
words = re.findall(r"\b[a-z]{4,}\b", lower)
for entry in CATEGORY_REGISTRY:
category = entry["category"]
for kw in entry["keywords"]:
for word in words:
if word in QUERY_STOP_WORDS:
continue
# Check close similarity for words >= 4 chars
ratio = difflib.SequenceMatcher(None, word, kw).ratio()
if ratio >= 0.8:
matches.append((category, kw, len(kw)))
break
return matches
def detect_category_from_text(text: str) -> Optional[str]:
"""Infer a single canonical category from free text (typically a user
query), or None if no category-identifying keyword is present.
When multiple categories match, the one listed earliest in
`CATEGORY_REGISTRY` wins (see module docstring); ties within that are
broken by the longest matched keyword.
"""
matches = _find_matches(text)
if not matches:
return None
priority = {cat: i for i, cat in enumerate(ALL_CATEGORIES)}
# Lower priority index = higher priority (earlier in registry).
matches.sort(key=lambda m: (priority.get(m[0], 999), -m[2]))
return matches[0][0]
def category_matches(product_category: Optional[str], target_category: Optional[str]) -> bool:
"""Normalized equality check between a product's stored category and a
target (e.g. detected-from-query) category. Used as a safety net after
retrieval so mismatched-category products never reach the user, even
if they scored well on raw embedding similarity."""
if not target_category:
return True
return _normalize(product_category) == _normalize(target_category)
def sanitize_category_language(description: str, product_category: Optional[str]) -> str:
"""Strip cross-category *identity claims* out of a generated description.
Blanket keyword replacement turned out to be too aggressive: FMCG copy
routinely mentions other categories as flavors or ingredients ("combines
the taste of cheese and crackers"), and replacing those reads as
nonsense even though they were never the source of the retrieval bug.
What actually caused a `Snacks` product to surface for a "biscuits"
query was the description *asserting an identity* it doesn't have -
"ITC Bingo Korean Style is a crispy, savory **biscuit**...". So this
function only rewrites that specific LLM-template shape:
"... is/are/was/were [a/an/the] [up to 4 words] KEYWORD ..."
e.g. "is a crispy, savory biscuit" -> "is a crispy, savory snack"
A flavor mention like "taste of cheese and crackers" doesn't match this
shape, so it's left alone rather than mangled into "taste of cracker".
Note: this deliberately does NOT try to repair descriptions that are
wrong in a deeper way (an entire sentence describing the wrong product
domain, e.g. a snack described as being "engineered to deliver powerful
cleaning") - a regex substitution can't safely rewrite a whole
hallucinated clause. Retrieval-time category filtering (see
`rag_service._filter_to_category`) is what actually protects the RAG
answers from that class of error, since it filters on the product's
stored `category` column rather than on description text; this
function only cleans up isolated noun-level mislabeling.
"""
if not description:
return description
own_category = product_category or ""
own_entry = next((e for e in CATEGORY_REGISTRY if e["category"] == own_category), None)
replacement = own_entry["generic_term"] if own_entry else _generic_fallback_term(own_category)
cleaned = description
for entry in CATEGORY_REGISTRY:
if entry["category"] == own_category:
continue # a product's own category words are never "leakage"
for kw in sorted(entry["keywords"], key=len, reverse=True):
kw_pattern = re.escape(kw)
pattern = re.compile(
r"\b(?:is|are|was|were)\s+(?:a|an|the)?\s*(?:[A-Za-z]+[,\s]+){0,4}?(" + kw_pattern + r")\b",
re.IGNORECASE,
)
def _replace(match: "re.Match[str]") -> str:
word = match.group(1)
repl_word = replacement[0].upper() + replacement[1:] if word[0].isupper() else replacement
rel_start = match.span(1)[0] - match.start()
return match.group(0)[:rel_start] + repl_word
cleaned = pattern.sub(_replace, cleaned)
return cleaned
def _generic_fallback_term(category: Optional[str]) -> str:
if not category or category.strip().lower() in ("", "uncategorized", "general"):
return "product"
return category.split("&")[0].split("-")[0].strip().lower() or "product"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,202 @@
"""
Lightweight intent parsing for free-text RAG queries.
Deliberately regex/keyword based (no extra model call) so it stays fast
and free on CPU-only hardware. Two things are extracted:
1. The product category the user is asking about, via
`category_registry.detect_category_from_text()` - re-exported here
for convenience so callers only need to import this module.
2. Simple product attributes (sugar level, spice level, dietary
flags) mentioned in the query, used to *re-rank* (never to
silently fabricate) retrieved products - see
`rag_service._rerank_by_attributes()`.
"""
from __future__ import annotations
import re
from typing import Dict, Optional
from app.services.category_registry import detect_category_from_text # noqa: F401 (re-exported)
# Each attribute maps a canonical value to the query phrases that imply it.
# Order matters: more specific phrases (e.g. "sugar free") are checked
# before more general ones (e.g. "low sugar").
_ATTRIBUTE_PATTERNS: Dict[str, Dict[str, list]] = {
"sugar": {
"none": [r"no added sugar", r"no sugar", r"sugar[- ]?free", r"zero sugar"],
"low": [r"low sugar", r"less sugar", r"reduced sugar"],
"high": [r"extra sweet", r"very sweet"],
},
"spice": {
"high": [r"spicy", r"extra hot", r"very hot", r"chilli", r"chili"],
"low": [r"mild", r"less spicy", r"not (?:too )?spicy"],
},
"fat": {
"low": [r"low fat", r"light\b"],
},
"gluten": {
"free": [r"gluten[- ]?free"],
},
"salt": {
"low": [r"low salt", r"less salt", r"low sodium"],
},
}
# Keyword hints (per attribute/value) to look for in a product's own text
# (description + highlights + nutrients) when deciding whether it actually
# satisfies the attribute the user asked for. Kept separate from the query
# patterns above because product copy tends to use slightly different
# phrasing than a user typing a search.
_PRODUCT_EVIDENCE_HINTS: Dict[str, Dict[str, list]] = {
"sugar": {
"none": ["no added sugar", "sugar free", "sugar-free", "zero sugar"],
"low": ["low sugar", "less sugar", "reduced sugar", "no added sugar", "sugar free"],
},
"spice": {
"high": ["spicy", "chilli", "chili", "hot & spicy", "extra hot"],
"low": ["mild", "less spicy"],
},
"fat": {
"low": ["low fat", "light"],
},
"gluten": {
"free": ["gluten free", "gluten-free"],
},
"salt": {
"low": ["low salt", "low sodium", "less salt"],
},
}
def extract_attributes(query: str) -> Dict[str, str]:
"""Return a dict of {attribute: value} implied by the query, e.g.
{"sugar": "low"} for "recommend biscuits with low sugar"."""
if not query:
return {}
lower = query.lower()
found: Dict[str, str] = {}
for attribute, values in _ATTRIBUTE_PATTERNS.items():
for value, patterns in values.items():
if any(re.search(p, lower) for p in patterns):
found[attribute] = value
break
return found
def product_matches_attribute(product_text: str, attribute: str, value: str) -> bool:
"""Whether a product's own text (description/highlights/nutrients)
contains evidence it satisfies the requested attribute value."""
hints = _PRODUCT_EVIDENCE_HINTS.get(attribute, {}).get(value)
if not hints:
return False
lower = (product_text or "").lower()
return any(hint in lower for hint in hints)
def is_count_query(query: str) -> bool:
"""Return True if the query asks for a total/count of products or brands."""
if not query:
return False
lower = query.lower()
patterns = [
r"how many",
r"count of",
r"total (?:number of )?(?:products|items|brands)",
r"number of products",
r"how many products",
r"how many items",
r"how many brands",
r"total count",
]
return any(re.search(p, lower) for p in patterns)
def extract_max_price(query: str) -> Optional[float]:
"""Extract a numeric maximum price ceiling from a query if present.
e.g. 'under ₹150' -> 150.0, 'less than 100 rupees' -> 100.0
"""
if not query:
return None
lower = query.lower().replace(",", "")
patterns = [
r"(?:under|below|less than|within|upto|up to|budget of|max(?:imum)?)\s*(?:₹|rs\.?|inr)?\s*(\d+(?:\.\d+)?)",
r"(?:₹|rs\.?|inr)\s*(\d+(?:\.\d+)?)\s*(?:or less|max|under|below)",
r"<\s*(?:₹|rs\.?|inr)?\s*(\d+(?:\.\d+)?)",
]
for p in patterns:
match = re.search(p, lower)
if match:
try:
val = float(match.group(1))
if val > 0:
return val
except ValueError:
pass
return None
KNOWN_BRANDS = [
"Amul", "Cadbury", "Cavinkare", "Coca-Cola", "Colgate-Palmolive", "Dabur",
"Godrej", "Grb", "Hindustan Unilever", "Lion Dates", "Manna", "Milky Mist",
"Naga", "Nestle", "P&G", "Pepsico"
]
BRAND_SEARCH_MAP = {
"cadbury": "Cadbury",
"amul": "Amul",
"cavinkare": "Cavinkare",
"coca-cola": "Coca-Cola",
"coca cola": "Coca-Cola",
"coke": "Coca-Cola",
"colgate-palmolive": "Colgate-Palmolive",
"colgate": "Colgate-Palmolive",
"dabur": "Dabur",
"godrej": "Godrej",
"grb": "Grb",
"hindustan unilever": "Hindustan Unilever",
"hul": "Hindustan Unilever",
"unilever": "Hindustan Unilever",
"lion dates": "Lion Dates",
"lion": "Lion Dates",
"manna": "Manna",
"milky mist": "Milky Mist",
"naga": "Naga",
"nestle": "Nestle",
"p&g": "P&G",
"pg": "P&G",
"pepsico": "Pepsico",
"pepsi": "Pepsico",
}
def extract_brand_mention(query: str) -> Optional[str]:
"""Detect if a brand name is explicitly mentioned in the query text."""
if not query:
return None
from app.services.brand_registry import BRAND_ALIASES, resolve_parent_brand
lower = query.lower()
# 1. Check direct search map
for alias in sorted(BRAND_SEARCH_MAP.keys(), key=len, reverse=True):
pattern = r"\b" + re.escape(alias) + r"\b"
if re.search(pattern, lower):
return BRAND_SEARCH_MAP[alias]
# 2. Check sub-brand aliases (e.g. "oreo", "maggi", "good day")
sorted_aliases = sorted(BRAND_ALIASES.keys(), key=len, reverse=True)
for alias in sorted_aliases:
pattern = r"\b" + re.escape(alias) + r"\b"
if re.search(pattern, lower):
parent = resolve_parent_brand(alias)
# Normalize to canonical known brand name case
for kb in KNOWN_BRANDS:
if kb.lower() == parent.lower():
return kb
return parent.title()
return None
def detected_category(query: str) -> Optional[str]:
"""Thin wrapper kept for readability at call sites in rag_service."""
return detect_category_from_text(query)

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

@@ -0,0 +1,351 @@
"""
RAG (Retrieval-Augmented Generation) orchestration.
This is the module that was missing from the original project: everything
needed to turn a free-text question into a grounded answer using the
catalog already sitting in pgvector.
Pipeline (all CPU-only, tuned for an 8GB RAM / no-GPU machine):
user query
-> embed_texts() (sentence-transformers, MiniLM-L6-v2, 384-dim)
-> vector_store.semantic_search() (pgvector cosine similarity, per-brand or all-brand)
-> build_context() (format top-k products into a compact text block)
-> ollama_service.generate_rag_answer() (qwen2.5:1.5b via local Ollama)
-> RagAnswer(answer, sources)
Kept deliberately simple and dependency-light (no LangChain/LlamaIndex) so
it stays easy to run and reason about on constrained hardware.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from app.infrastructure.settings import (
RAG_DEFAULT_TOP_K,
RAG_MAX_TOP_K,
RAG_MAX_CONTEXT_CHARS,
RAG_MAX_DISTANCE,
)
from app.services.category_registry import category_matches, detect_category_from_text
from app.services.embeddings_service import embed_texts
from app.services.ollama_service import generate_rag_answer
from app.services.query_intent import (
extract_attributes,
extract_brand_mention,
extract_max_price,
is_count_query,
product_matches_attribute,
)
from app.services.s3_service import s3_service
from app.services.vector_store import (
count_products_all_brands,
count_products_by_brand,
list_available_brands,
list_categories_for_brand,
semantic_search,
text_search,
)
logger = logging.getLogger(__name__)
@dataclass
class RetrievedProduct:
image_id: str
image_url: str = ""
image_urls: List[str] = field(default_factory=list)
brand: str = ""
title: str = ""
product_name: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
price_range: Optional[str] = None
size_variants: List[str] = field(default_factory=list)
providers: List[str] = field(default_factory=list)
highlights: List[str] = field(default_factory=list)
nutrients: List[str] = field(default_factory=list)
fssai_license: Optional[str] = None
product_sku: Optional[str] = None
sku_source: Optional[str] = None
hsn_code: Optional[str] = None
final_selling_price: Optional[float] = None
selling_price: Optional[float] = None
barcode: Optional[str] = None
barcode_type: Optional[str] = None
distance: float = 1.0
@property
def similarity(self) -> float:
"""Convert pgvector cosine distance (0=identical) to an intuitive 0-1 score."""
return max(0.0, min(1.0, 1.0 - (self.distance / 2.0)))
def to_dict(self) -> Dict[str, Any]:
return {
"image_id": self.image_id,
"image_url": self.image_url,
"image_urls": self.image_urls,
"brand": self.brand,
"title": self.title,
"product_name": self.product_name,
"category": self.category,
"description": self.description,
"price_range": self.price_range,
"size_variants": self.size_variants,
"providers": self.providers,
"highlights": self.highlights,
"nutrients": self.nutrients,
"fssai_license": self.fssai_license,
"product_sku": self.product_sku,
"sku_source": self.sku_source,
"hsn_code": self.hsn_code,
"final_selling_price": self.final_selling_price,
"selling_price": self.selling_price,
"barcode": self.barcode,
"barcode_type": self.barcode_type,
"similarity": round(self.similarity, 4),
}
@dataclass
class RagAnswer:
answer: str
sources: List[RetrievedProduct]
query: str
brand: Optional[str]
detected_category: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"answer": self.answer,
"query": self.query,
"brand": self.brand,
"detected_category": self.detected_category,
"sources": [s.to_dict() for s in self.sources],
}
def _clean_url(url: Optional[str]) -> Optional[str]:
if not url:
return None
return str(url).replace('{width}', '800')
def _row_to_retrieved_product(row: Dict[str, Any]) -> RetrievedProduct:
image_id = row.get("image_id") or ""
brand = (row.get("brand") or "").title()
db_single = _clean_url(row.get("image_url"))
db_list = [_clean_url(u) for u in (row.get("image_urls") or []) if u]
final_urls = db_list
if not final_urls and db_single:
final_urls = [db_single]
if not final_urls and s3_service.enabled and image_id:
s3_list = s3_service.get_product_image_urls(brand, image_id)
if s3_list:
final_urls = s3_list
primary_url = (final_urls[0] if final_urls else None) or db_single
if not primary_url and s3_service.enabled and image_id:
primary_url = s3_service.get_product_image_url(brand, image_id)
hsn = row.get("hsn_code") or row.get("HSN_Code") or row.get("hsn") or None
if hsn is not None:
hsn = str(hsn).strip() or None
raw_fsp = row.get("final_selling_price") if "final_selling_price" in row else row.get("Final_Selling_Price")
if raw_fsp is None:
raw_fsp = row.get("final_price")
try:
fsp = float(raw_fsp) if raw_fsp is not None and str(raw_fsp).strip() != "" else None
except (ValueError, TypeError):
fsp = None
raw_sp = row.get("selling_price") if "selling_price" in row else row.get("Selling_Price")
try:
sp = float(raw_sp) if raw_sp is not None and str(raw_sp).strip() != "" else None
except (ValueError, TypeError):
sp = None
if fsp is None and sp is not None:
fsp = sp
bcd = row.get("barcode") or row.get("Barcode") or None
if bcd is not None:
bcd = str(bcd).strip() or None
bcd_type = row.get("barcode_type") or row.get("Barcode_Type") or None
if bcd_type is not None:
bcd_type = str(bcd_type).strip() or None
fssai = row.get("fssai_license") or row.get("fssai") or row.get("fssai_number") or row.get("FSSAI_License") or row.get("fssai_lic_no") or None
if fssai is not None:
fssai = str(fssai).strip() or None
return RetrievedProduct(
image_id=image_id,
image_url=primary_url,
image_urls=final_urls,
brand=brand,
product_name=row.get("product_name") or row.get("title") or "Unknown product",
title=row.get("title") or row.get("product_name") or None,
category=row.get("category"),
description=row.get("description"),
price_range=row.get("price_range"),
size_variants=list(row.get("size_variants") or []),
providers=list(row.get("providers") or []),
highlights=list(row.get("highlights") or []),
nutrients=list(row.get("nutrients") or []),
fssai_license=fssai,
product_sku=row.get("product_sku") or None,
sku_source=row.get("sku_source") or None,
hsn_code=hsn,
final_selling_price=fsp,
selling_price=sp,
barcode=bcd,
barcode_type=bcd_type,
distance=float(row.get("distance", 1.0)),
)
def _filter_to_category(rows: List[Dict[str, Any]], target_category: Optional[str]) -> List[Dict[str, Any]]:
if not target_category:
return rows
return [r for r in rows if category_matches(r.get("category"), target_category)]
def _rerank_by_attributes(products: List[RetrievedProduct], attrs: Dict[str, str]) -> List[RetrievedProduct]:
"""Move products with explicit evidence of a requested attribute (e.g.
"sugar": "low") to the front, without dropping any product.
"""
def evidence_count(p: RetrievedProduct) -> int:
text = " ".join(filter(None, [p.description or "", " ".join(p.highlights), " ".join(p.nutrients)]))
return sum(
1 for attribute, value in attrs.items()
if product_matches_attribute(text, attribute, value)
)
indexed = list(enumerate(products))
indexed.sort(key=lambda pair: (-evidence_count(pair[1]), pair[0]))
return [p for _, p in indexed]
def retrieve(query: str, brand: Optional[str] = None, top_k: Optional[int] = None,
category: Optional[str] = None, max_distance: Optional[float] = None) -> List[RetrievedProduct]:
"""Embed `query` and run a pgvector similarity search with category, brand, and price filtering."""
top_k = min(top_k or RAG_DEFAULT_TOP_K, RAG_MAX_TOP_K)
effective_max_distance = max_distance if max_distance is not None else RAG_MAX_DISTANCE
target_brand = brand or extract_brand_mention(query)
target_category = category or detect_category_from_text(query)
max_price = extract_max_price(query)
try:
vectors = embed_texts([query])
except Exception as e:
logger.warning("Embedding model failed: %s. Using text fallback.", e)
vectors = None
rows: List[Dict[str, Any]] = []
if vectors:
rows = semantic_search(
query_embedding=vectors[0],
brand=target_brand,
top_k=top_k,
category=target_category,
max_distance=effective_max_distance,
max_price=max_price,
)
rows = _filter_to_category(rows, target_category)
if not rows:
logger.info(
"Semantic search returned no in-category results for %r (category=%r), falling back to text search",
query, target_category,
)
if not rows:
rows = text_search(query, brand=target_brand, top_k=top_k, category=target_category, max_price=max_price)
rows = _filter_to_category(rows, target_category)
products = [_row_to_retrieved_product(r) for r in rows]
attrs = extract_attributes(query)
if attrs and products:
products = _rerank_by_attributes(products, attrs)
return products
def build_context(products: List[RetrievedProduct], max_chars: int = RAG_MAX_CONTEXT_CHARS,
metric_info: Optional[str] = None) -> str:
"""Format retrieved products and optional metric info into a compact text block for the LLM prompt."""
prefix = f"{metric_info}\n\n" if metric_info else ""
if not products:
return prefix + "(No matching products were found in the catalog for this query.)"
per_item_budget = max(200, max_chars // max(1, len(products)))
lines = []
for i, p in enumerate(products, start=1):
desc = (p.description or "").strip()
budget_for_desc = max(40, per_item_budget - 120)
if len(desc) > budget_for_desc:
desc = desc[:budget_for_desc].rsplit(" ", 1)[0] + "..."
parts = [f"{i}. {p.product_name or p.title} (brand: {p.brand}, category: {p.category or 'Uncategorized'})"]
if p.price_range:
parts.append(f" Price: {p.price_range}")
if p.size_variants:
parts.append(f" Sizes: {', '.join(p.size_variants[:6])}")
if p.highlights:
parts.append(f" Highlights: {', '.join(p.highlights[:5])}")
if p.nutrients:
parts.append(f" Nutrients: {', '.join(p.nutrients[:5])}")
if desc:
parts.append(f" Description: {desc}")
lines.append("\n".join(parts))
return prefix + "\n\n".join(lines)
def answer_query(
query: str,
brand: Optional[str] = None,
top_k: Optional[int] = None,
category: Optional[str] = None,
history: Optional[List[Dict[str, str]]] = None,
) -> RagAnswer:
"""End-to-end RAG: retrieve relevant products, compute exact counts if requested, then generate a grounded answer."""
target_brand = brand or extract_brand_mention(query)
target_category = category or detect_category_from_text(query)
metric_info: Optional[str] = None
if is_count_query(query):
if target_brand:
cnt = count_products_by_brand(target_brand, category=target_category)
cats = list_categories_for_brand(target_brand)
cat_str = f" in category '{target_category}'" if target_category else ""
metric_info = (
f"CATALOG METRIC DATA: Exact product count for brand '{target_brand}'{cat_str} in the database is {cnt}. "
f"Categories available under {target_brand}: {', '.join(cats)}."
)
elif target_category:
cnt = count_products_all_brands(category=target_category)
metric_info = f"CATALOG METRIC DATA: Total products matching category '{target_category}' across all brands in database is {cnt}."
else:
total = count_products_all_brands()
brands = list_available_brands()
metric_info = f"CATALOG METRIC DATA: Total catalog items across all brands is {total}. Known brands: {', '.join(brands)}."
products = retrieve(query, brand=brand, top_k=top_k, category=category)
context = build_context(products, metric_info=metric_info)
answer_text = generate_rag_answer(query, context, history=history, requested_category=target_category)
return RagAnswer(
answer=answer_text, sources=products, query=query, brand=target_brand or brand,
detected_category=target_category,
)

View File

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

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

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

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

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

View File

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

View File

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

View File

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