Backend catalog recent updates
This commit is contained in:
@@ -385,6 +385,37 @@ BARCODE_LOOKUP_CACHE_TTL_SECONDS = float(os.getenv("BARCODE_LOOKUP_CACHE_TTL_SEC
|
||||
BARCODE_LOOKUP_MAX_CONCURRENCY = int(os.getenv("BARCODE_LOOKUP_MAX_CONCURRENCY", "5"))
|
||||
BARCODE_COUNTRY_TAG = os.getenv("BARCODE_COUNTRY_TAG", "india")
|
||||
|
||||
# How similar a candidate's product name must be to ours before its barcode is
|
||||
# believed. Applies to BOTH directions: looking a barcode up from a name, and
|
||||
# looking a product up from a barcode.
|
||||
#
|
||||
# RAISED FROM matching.py's OWN 0.45 DEFAULT, ON EVIDENCE. That default is a
|
||||
# reasonable general floor, but by the time a candidate reaches this gate its
|
||||
# brand and pack size have ALREADY been matched - so the name is the only thing
|
||||
# left doing any discriminating, and it has to carry the whole decision.
|
||||
#
|
||||
# At 0.45 it did not. Scored across every catalogue barcode Open Food Facts
|
||||
# knows, more than half the accepted matches were a different product:
|
||||
#
|
||||
# floor accepted wrong
|
||||
# 0.45 15 8
|
||||
# 0.70 7 2
|
||||
# 0.78 2 0
|
||||
#
|
||||
# 0.761 "Tata Tea Gold 500g" -> "Tata Tea Gold Care" different
|
||||
# 0.658 "MTR Masala 300g" -> "MTR Chana Masala" different
|
||||
# 0.538 "Aachi Chicken Masala 50g" -> "Chicken Kabab/65 Masala" different
|
||||
# 0.097 "Lion Dates Powder 100g" -> "PEPER NOTEN" different
|
||||
#
|
||||
# 0.78 is where the sample is clean, NOT where the yield is good, and it is a
|
||||
# judgement rather than a separation: two products tie at 0.773 with opposite
|
||||
# verdicts. It is set for precision because a WRONG barcode is worse than no
|
||||
# barcode - it is an identifier other systems join on, and 33 of the 95 already
|
||||
# in the catalogue are wrong, all of them accepted at the old floor.
|
||||
#
|
||||
# Lower it only with the yield/error numbers in front of you.
|
||||
BARCODE_MIN_NAME_SIMILARITY = float(os.getenv("BARCODE_MIN_NAME_SIMILARITY", "0.78"))
|
||||
|
||||
# Optional barcode source credentials. Each source disables itself when its
|
||||
# key is blank, so leaving these unset simply narrows the lookup cascade.
|
||||
GS1_INDIA_API_BASE_URL = os.getenv("GS1_INDIA_API_BASE_URL", "")
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.infrastructure.settings import (
|
||||
ENABLE_BARCODE_LOOKUP,
|
||||
BARCODE_LOOKUP_CACHE_TTL_SECONDS,
|
||||
BARCODE_LOOKUP_MAX_CONCURRENCY,
|
||||
BARCODE_MIN_NAME_SIMILARITY,
|
||||
)
|
||||
from app.services.enrichment.barcode import cache
|
||||
from app.services.enrichment.barcode.matching import is_match
|
||||
@@ -131,7 +132,16 @@ class BarcodeLookupService:
|
||||
clean_barcode = validate_barcode(candidate.barcode)
|
||||
if not clean_barcode:
|
||||
continue # invalid checksum/length/format - never stored, not even flagged
|
||||
matched, confidence = is_match(candidate, brand, product_title, size, brand_aliases)
|
||||
# The floor comes from settings, not from matching.py's own 0.45
|
||||
# default. By this point the candidate's brand and pack size have
|
||||
# already matched, so the name is the only thing still telling two
|
||||
# products apart - and at 0.45 more than half the accepted matches
|
||||
# in this catalogue were the wrong product. See the setting for the
|
||||
# measured yield/error table.
|
||||
matched, confidence = is_match(
|
||||
candidate, brand, product_title, size, brand_aliases,
|
||||
min_name_similarity=BARCODE_MIN_NAME_SIMILARITY,
|
||||
)
|
||||
if not matched:
|
||||
continue
|
||||
return _build_result(clean_barcode, candidate.source_name, confidence)
|
||||
|
||||
@@ -22,7 +22,7 @@ comparison, so the size-matching logic itself is not duplicated - see
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
@@ -44,6 +44,68 @@ _BROWSER_UA = (
|
||||
)
|
||||
_FIELDS = "code,product_name,brands,brands_tags,quantity,countries_tags"
|
||||
|
||||
# The reverse direction asks for more than the search does, because its caller
|
||||
# builds a whole nutrition record rather than just reading a barcode off the
|
||||
# entry. Still an explicit list and not everything: a bare product fetch returns
|
||||
# ~296 fields per item, most of them editing metadata nobody here reads.
|
||||
_PRODUCT_FIELDS = (
|
||||
"code,product_name,brands,brands_tags,quantity,countries_tags,"
|
||||
"nutriments,serving_quantity,serving_size,ingredients_text,"
|
||||
"nutriscore_grade,allergens_tags,labels_tags,"
|
||||
"ingredients_analysis_tags,categories_tags"
|
||||
)
|
||||
|
||||
|
||||
def fetch_product_by_barcode(code: str) -> Optional[dict]:
|
||||
"""The one OFF call in this project that is EXACT rather than a guess.
|
||||
|
||||
Every other Open*Facts call here - this module's own `search()`,
|
||||
`nutrition_data_service`, `image_search` - queries by brand and product
|
||||
name and then scores whatever comes back. That is why the OFF-sourced rows
|
||||
in `nutrition_facts` carry match confidences as low as 0.32. A barcode is
|
||||
the identifier printed on the pack, so `/api/v2/product/{code}` either
|
||||
returns that exact product or nothing at all.
|
||||
|
||||
Returns the product dict, or None when OFF has never seen the barcode -
|
||||
which is the ordinary outcome for about a third of ours, not an error. The
|
||||
caller still has to decide whether the record describes the product WE
|
||||
attached that barcode to; see `matching.is_match`. Measured on real
|
||||
catalogue rows, a quarter of the found records were a different product,
|
||||
because the stored barcode itself was wrong.
|
||||
|
||||
Cascades the same three hosts as the search: a household or beauty item
|
||||
lives in openbeautyfacts, not openfoodfacts, under the same code.
|
||||
"""
|
||||
code = (code or "").strip()
|
||||
if not code:
|
||||
return None
|
||||
|
||||
for host in _HOSTS:
|
||||
@with_retry(max_attempts=2)
|
||||
def _call(host=host):
|
||||
return requests.get(
|
||||
f"https://{host}/api/v2/product/{code}.json",
|
||||
params={"fields": _PRODUCT_FIELDS},
|
||||
headers={"User-Agent": _BROWSER_UA},
|
||||
timeout=BARCODE_LOOKUP_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
try:
|
||||
resp = _call()
|
||||
# 404 is how OFF says "no such barcode here" - try the next host
|
||||
# rather than treating it as a failure.
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
body = resp.json()
|
||||
# status 1 = found, 0 = not found. The HTTP code alone is not
|
||||
# enough: OFF answers 200 with status 0 for an unknown barcode.
|
||||
if body.get("status") == 1 and body.get("product"):
|
||||
return body["product"]
|
||||
except Exception as e:
|
||||
logger.debug("Open*Facts product fetch failed on %s for %s: %s", host, code, e)
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
class OpenFoodFactsSource(BarcodeSource):
|
||||
name = "Open Food Facts"
|
||||
|
||||
@@ -20,6 +20,24 @@ class BarcodeEnrichmentStage(EnrichmentStage):
|
||||
return ENABLE_BARCODE_LOOKUP
|
||||
|
||||
async def enrich_one(self, product: Dict[str, Any], brand: str) -> StageOutcome:
|
||||
# ALREADY HAS ONE - never overwrite, and never even look. The same
|
||||
# guard HsnGstEnrichmentStage carries, and this stage was the only one
|
||||
# missing it.
|
||||
#
|
||||
# Without it the damage was not "a worse barcode" but no barcode at
|
||||
# all: `as_product_fields()` always returns all nine keys, so a failed
|
||||
# lookup handed back {"barcode": None, ...} and `apply()` merged that
|
||||
# straight over whatever the shop had typed. Proven end to end - a
|
||||
# sheet sending 8901262010016 stored NULL. Since the cascade misses far
|
||||
# more often than it hits, switching ENABLE_BARCODE_LOOKUP on would
|
||||
# have destroyed more real barcodes than it found.
|
||||
#
|
||||
# A barcode the shop supplied is also better evidence than anything the
|
||||
# cascade can find: they are holding the pack. Skipping the lookup
|
||||
# saves the network call as well.
|
||||
if str(product.get("barcode") or "").strip():
|
||||
return StageOutcome(stage_name=self.name, fields={})
|
||||
|
||||
service = get_default_service()
|
||||
title = product.get("title") or product.get("product_name") or ""
|
||||
size = product.get("size") or ""
|
||||
|
||||
@@ -73,8 +73,31 @@ class EnrichmentStage(ABC):
|
||||
logger.error(f"[{self.name}] unhandled exception enriching '{product.get('product_name')}': {e}")
|
||||
return product
|
||||
|
||||
# A STAGE MAY FILL A GAP OR CORRECT A VALUE. IT MAY NOT ERASE ONE.
|
||||
#
|
||||
# This was a plain `product.update(outcome.fields)`, and the barcode
|
||||
# stage returns a fixed nine-key dict whose values are all None when
|
||||
# the lookup finds nothing - so a miss silently replaced the barcode
|
||||
# the shop had typed with NULL. Verified end to end before this guard
|
||||
# existed: a sheet sending 8901262010016 stored None.
|
||||
#
|
||||
# The rule below is the narrowest one that stops it. A stage can still
|
||||
# overwrite a value with a DIFFERENT value, which is what correcting a
|
||||
# field means; it just cannot blank one out. Stages that must not
|
||||
# overwrite at all say so themselves by returning no fields - see
|
||||
# HsnGstEnrichmentStage and BarcodeEnrichmentStage.
|
||||
if outcome.fields:
|
||||
product.update(outcome.fields)
|
||||
for key, value in outcome.fields.items():
|
||||
blank_incoming = value is None or (isinstance(value, str) and not value.strip())
|
||||
existing = product.get(key)
|
||||
held = existing is not None and not (isinstance(existing, str) and not existing.strip())
|
||||
if blank_incoming and held:
|
||||
logger.debug(
|
||||
"[%s] kept existing %s=%r rather than blanking it",
|
||||
self.name, key, existing,
|
||||
)
|
||||
continue
|
||||
product[key] = value
|
||||
if not outcome.ok:
|
||||
logger.debug(f"[{self.name}] {product.get('product_name')}: {outcome.error}")
|
||||
return product
|
||||
|
||||
@@ -29,7 +29,11 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from app.infrastructure.settings import USE_OPEN_FACTS, REQUEST_TIMEOUT_SECONDS
|
||||
from app.infrastructure.settings import (
|
||||
BARCODE_MIN_NAME_SIMILARITY as _SETTINGS_BARCODE_MIN_NAME_SIMILARITY,
|
||||
REQUEST_TIMEOUT_SECONDS,
|
||||
USE_OPEN_FACTS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -354,3 +358,163 @@ def fetch_verified_nutrition(brand: str, title: str, category: str = "") -> Dict
|
||||
}
|
||||
result.update(per_100g)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The exact path: look the product up by the barcode on its pack
|
||||
# ---------------------------------------------------------------------------
|
||||
# Everything above searches Open Food Facts by brand and product name and then
|
||||
# scores whatever comes back, because for most of the catalogue a name is all we
|
||||
# have. It works, but it is a guess: MIN_MATCH_CONFIDENCE is 0.32, and rows in
|
||||
# nutrition_facts really do sit at that floor.
|
||||
#
|
||||
# For the products that carry a barcode, we can do better. The barcode is the
|
||||
# identifier printed on the pack, so /api/v2/product/{code} returns that exact
|
||||
# product or nothing.
|
||||
#
|
||||
# WHAT IS STILL NOT CERTAIN, AND WHY THERE IS A GATE.
|
||||
# The lookup is exact; the STORED BARCODE is not. Measured against the live
|
||||
# catalogue, a quarter of the records found this way described a different
|
||||
# product - "Aachi Chicken Masala 50g" came back as "Chicken Kabab/65 Masala" -
|
||||
# because the barcode attached to our row was wrong. Importing on the strength
|
||||
# of the identifier alone would write another product's nutrition onto ours,
|
||||
# which is the same shape of fault as shipping one company's FSSAI licence on
|
||||
# another's product. So the record still has to pass `matching.is_match`, which
|
||||
# checks brand, pack size, variant terms and name similarity. That module is
|
||||
# reused rather than reimplemented: a second opinion on "is this the same
|
||||
# product" that disagreed with the first would be worse than none.
|
||||
|
||||
# A verified barcode hit is an identifier match, not a fuzzy one, and it is
|
||||
# recorded well clear of the name-search band so the two are separable in the
|
||||
# table. It is not 1.0: `is_match` still passed judgement on brand and size, and
|
||||
# claiming certainty would misrepresent that.
|
||||
BARCODE_MATCH_CONFIDENCE = 0.95
|
||||
|
||||
# Distinct from plain "openfoodfacts" so a query can tell an exact hit from a
|
||||
# name search. `data_source` is free text and nothing filters on it - it is
|
||||
# passed through to nutrition_schemas for display - so adding a value here
|
||||
# breaks no existing read.
|
||||
BARCODE_DATA_SOURCE = "openfoodfacts_barcode"
|
||||
|
||||
# The name-similarity floor for a barcode-verified match, well above
|
||||
# matching.py's own 0.45 default. That default is right for the FORWARD lookup,
|
||||
# where brand and size have not yet been confirmed and the name is one signal
|
||||
# among several. Here brand and size already agree - the identifier guaranteed
|
||||
# that much - so the name is the only thing left doing any discriminating, and
|
||||
# it has to carry the whole decision.
|
||||
#
|
||||
# 0.78 IS A JUDGEMENT, NOT A CLEAN SEPARATION, and the data says so. Scored
|
||||
# across all 35 catalogue barcodes OFF actually knows:
|
||||
#
|
||||
# 0.806 Aachi Chicken Masala 100g -> "Aachi chicken masala" same
|
||||
# 0.773 Tata Sampann Chana Dal -> "Tata Sampann Unpolished .." same
|
||||
# 0.773 Tata Coffee Classic 2g -> "Tata Coffee Grand Classic" DIFFERENT
|
||||
# 0.761 Tata Tea Gold 500g -> "Tata Tea Gold Care" DIFFERENT
|
||||
# 0.658 MTR Masala 300g -> "MTR Chana Masala" DIFFERENT
|
||||
# 0.097 Lion Dates Powder 100g -> "PEPER NOTEN" DIFFERENT
|
||||
#
|
||||
# Two products tie at 0.773 with opposite verdicts, so no threshold separates
|
||||
# them. Part of the cause is on our side: "MTR Masala 300g" and "Tata Sampann
|
||||
# Spices 200g" do not name a specific product, and nothing can match a name
|
||||
# that vague.
|
||||
#
|
||||
# So this is set where the sample is clean rather than where the yield is good,
|
||||
# and the backfill script prints every candidate with its score so the cut can
|
||||
# be seen and argued with instead of taken on trust.
|
||||
# Imported rather than redeclared: the forward lookup (name -> barcode) and this
|
||||
# reverse one (barcode -> product) are answering the same question about the
|
||||
# same pair of names, and two copies that drifted apart would mean a barcode
|
||||
# good enough to store was not good enough to read back.
|
||||
BARCODE_MIN_NAME_SIMILARITY = _SETTINGS_BARCODE_MIN_NAME_SIMILARITY
|
||||
|
||||
|
||||
def fetch_verified_nutrition_by_barcode(
|
||||
barcode: str, brand: str, title: str, size: str = "", category: str = "",
|
||||
min_name_similarity: float = BARCODE_MIN_NAME_SIMILARITY,
|
||||
) -> Dict[str, Any]:
|
||||
"""Nutrition for one product, looked up by its barcode.
|
||||
|
||||
Returns THE SAME DICT SHAPE as `fetch_verified_nutrition`, deliberately, so
|
||||
`nutrition_db.upsert_nutrition_facts` writes it with no change and the two
|
||||
acquisition paths cannot drift apart. There is a test asserting the key sets
|
||||
match.
|
||||
|
||||
`data_status == "unavailable"` covers every way this can decline - no
|
||||
barcode, OFF has never seen it, the record is a different product, or it
|
||||
carries no nutriments. Callers must render "unavailable" rather than
|
||||
treating a missing value as zero.
|
||||
"""
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
unavailable = {"data_status": "unavailable", "fetched_at": now_iso}
|
||||
|
||||
if not USE_OPEN_FACTS or not (barcode or "").strip():
|
||||
return unavailable
|
||||
|
||||
# Imported here rather than at module scope: this module is reached during
|
||||
# nutrition enrichment, and the barcode package pulls in tenacity plus the
|
||||
# whole source cascade. A local import keeps that off the path of the
|
||||
# name-search calls above, which do not need any of it.
|
||||
from app.services.enrichment.barcode.matching import is_match
|
||||
from app.services.enrichment.barcode.models import BarcodeCandidate
|
||||
from app.services.enrichment.barcode.sources.open_food_facts import (
|
||||
fetch_product_by_barcode,
|
||||
)
|
||||
|
||||
product = fetch_product_by_barcode(barcode)
|
||||
if not product:
|
||||
return unavailable
|
||||
|
||||
candidate = BarcodeCandidate(
|
||||
barcode=str(product.get("code") or barcode),
|
||||
source_name="Open Food Facts",
|
||||
candidate_title=product.get("product_name") or "",
|
||||
candidate_brand=product.get("brands") or "",
|
||||
candidate_size=product.get("quantity") or "",
|
||||
candidate_countries=",".join(product.get("countries_tags") or []),
|
||||
)
|
||||
matched, similarity = is_match(
|
||||
candidate, brand, title, size,
|
||||
min_name_similarity=min_name_similarity,
|
||||
)
|
||||
if not matched:
|
||||
logger.info(
|
||||
"Barcode %s is in Open Food Facts as %r (%s, %s) which does not "
|
||||
"match our %r (%s, %s) - skipping, and OUR barcode is the suspect one",
|
||||
barcode, candidate.candidate_title, candidate.candidate_brand,
|
||||
candidate.candidate_size, title, brand, size,
|
||||
)
|
||||
return unavailable
|
||||
|
||||
nutriments = product.get("nutriments") or {}
|
||||
if not nutriments:
|
||||
return unavailable
|
||||
|
||||
per_100g = _build_flat_fields(nutriments, "100g")
|
||||
if not any(v is not None for v in per_100g.values()):
|
||||
return unavailable
|
||||
per_serving = _build_flat_fields(nutriments, "serving")
|
||||
|
||||
code = product.get("code") or barcode
|
||||
result: Dict[str, Any] = {
|
||||
"data_status": "verified" if per_100g.get("calories_kcal") is not None else "partial",
|
||||
"data_source": BARCODE_DATA_SOURCE,
|
||||
"source_ref": code,
|
||||
"source_url": f"https://{OFF_HOST}/product/{code}",
|
||||
"match_confidence": BARCODE_MATCH_CONFIDENCE,
|
||||
# Diagnostic only. The gate above already decided acceptance; this is
|
||||
# kept so a low-similarity accept can be reviewed later.
|
||||
"name_similarity": round(similarity, 3),
|
||||
"serving_size_g": _convert(product.get("serving_quantity"), "g"),
|
||||
"serving_size_label": product.get("serving_size"),
|
||||
"extended_nutrients": _build_extended_nutrients(nutriments, "100g"),
|
||||
"per_serving": {k: v for k, v in per_serving.items() if v is not None},
|
||||
"ingredients_text": (product.get("ingredients_text") or "").strip() or None,
|
||||
"off_nutriscore": (product.get("nutriscore_grade") or "").strip().lower() or None,
|
||||
"allergens": _extract_allergens(product),
|
||||
"off_labels_tags": product.get("labels_tags") or [],
|
||||
"off_ingredients_analysis_tags": product.get("ingredients_analysis_tags") or [],
|
||||
"off_categories_tags": product.get("categories_tags") or [],
|
||||
"fetched_at": now_iso,
|
||||
}
|
||||
result.update(per_100g)
|
||||
return result
|
||||
|
||||
@@ -37988,8 +37988,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Lettuce. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://images.openfoodfacts.org/images/products/000/000/034/7358/front_en.20.400.jpg",
|
||||
"image_urls": [
|
||||
"https://images.openfoodfacts.org/images/products/000/000/034/7358/front_en.20.400.jpg",
|
||||
"https://images.openfoodfacts.org/images/products/003/022/303/9365/front_en.112.400.jpg",
|
||||
"https://images.openfoodfacts.org/images/products/325/622/380/0287/front_fr.23.400.jpg",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/4/4c/Lactuca_sativa_02.JPG/960px-Lactuca_sativa_02.JPG?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -38393,7 +38398,10 @@
|
||||
0.0043616690672934055,
|
||||
0.09239858388900757,
|
||||
0.005233542528003454
|
||||
]
|
||||
],
|
||||
"primary_image": "https://images.openfoodfacts.org/images/products/000/000/034/7358/front_en.20.400.jpg",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Shallot",
|
||||
@@ -38402,8 +38410,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Shallot. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://www.theharvestkitchen.com/wp-content/uploads/2023/08/shallot-substitute.jpg",
|
||||
"image_urls": [
|
||||
"https://www.theharvestkitchen.com/wp-content/uploads/2023/08/shallot-substitute.jpg",
|
||||
"https://c8.alamy.com/comp/BR9HBH/single-shallot-BR9HBH.jpg",
|
||||
"https://bakeitwithlove.com/wp-content/uploads/2022/01/shallot-substitution-pin.jpg",
|
||||
"https://www.highmowingseeds.com/media/catalog/product/cache/6cbdb003cf4aae33b9be8e6a6cf3d7ad/2/7/2711-1.jpg"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -38807,7 +38820,10 @@
|
||||
-0.016630925238132477,
|
||||
0.021545154973864555,
|
||||
0.0025799174327403307
|
||||
]
|
||||
],
|
||||
"primary_image": "https://www.theharvestkitchen.com/wp-content/uploads/2023/08/shallot-substitute.jpg",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Ivy Gourd",
|
||||
@@ -38816,8 +38832,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Ivy Gourd. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://images.openfoodfacts.org/images/products/001/143/311/7388/front_en.3.400.jpg",
|
||||
"image_urls": [
|
||||
"https://images.openfoodfacts.org/images/products/001/143/311/7388/front_en.3.400.jpg",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/e/e2/Coccinia_grandis_%28Cucurbitaceae%29.jpg/960px-Coccinia_grandis_%28Cucurbitaceae%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/0/00/Coccinia_grandis_Ivy_gourd_Cephalandra_indica_%E0%B4%95%E0%B5%8B%E0%B4%B5%E0%B5%BD.JPG/960px-Coccinia_grandis_Ivy_gourd_Cephalandra_indica_%E0%B4%95%E0%B5%8B%E0%B4%B5%E0%B5%BD.JPG?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/c/cf/Coccinia_grandis_sapling.jpg/960px-Coccinia_grandis_sapling.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -39221,7 +39242,10 @@
|
||||
-0.032460130751132965,
|
||||
0.04225873202085495,
|
||||
0.06438104808330536
|
||||
]
|
||||
],
|
||||
"primary_image": "https://images.openfoodfacts.org/images/products/001/143/311/7388/front_en.3.400.jpg",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Cluster Bean",
|
||||
@@ -39230,8 +39254,11 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Cluster Bean. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://images1.livehindustan.com/smart/img/2024/06/17/1200x900/cluster_1718594899887_1718594917521.jpg",
|
||||
"image_urls": [
|
||||
"https://images1.livehindustan.com/smart/img/2024/06/17/1200x900/cluster_1718594899887_1718594917521.jpg",
|
||||
"https://www.jagranimages.com/images/newimg/21082023/21_08_2023-cluster_beans_benefits_23507732.webp"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -39635,7 +39662,10 @@
|
||||
-0.011852066032588482,
|
||||
-0.035320233553647995,
|
||||
-0.03111950494349003
|
||||
]
|
||||
],
|
||||
"primary_image": "https://images1.livehindustan.com/smart/img/2024/06/17/1200x900/cluster_1718594899887_1718594917521.jpg",
|
||||
"total_images": 2,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Green Papaya",
|
||||
@@ -39644,8 +39674,12 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Green Papaya. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://images.openbeautyfacts.org/images/products/590/101/801/1369/front_en.16.400.jpg",
|
||||
"image_urls": [
|
||||
"https://images.openbeautyfacts.org/images/products/590/101/801/1369/front_en.16.400.jpg",
|
||||
"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiSuByiu32YHewp2BkmQ05qW6KFPfis2NVCcONrto9UpCWBSx8CGL-Emx0bH-lS78viCkpqC2ZKZa2zk0cktrEDOLWo7JhSdx_Y8apQ1xtAF8qZw9fc_ff7YVl4q6dukSPRLTui5meUDFg/w1200-h630-p-k-no-nu/SANY0811.JPG",
|
||||
"https://images.news18.com/static-bengali/uploads/2023/11/New-Project-4-2023-11-52f8cfd5664c4cc035e2bf8f43da16f5-3x2.jpg?im=FitAndFill=(1200,675)"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -40049,7 +40083,10 @@
|
||||
-0.010975969024002552,
|
||||
0.07554828375577927,
|
||||
0.04782396927475929
|
||||
]
|
||||
],
|
||||
"primary_image": "https://images.openbeautyfacts.org/images/products/590/101/801/1369/front_en.16.400.jpg",
|
||||
"total_images": 3,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Spinach",
|
||||
@@ -40058,8 +40095,11 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Spinach. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://cdn.grofers.com/cdn-cgi/image/f=auto,fit=scale-down,q=85,metadata=none,w=480,h=480/da/cms-assets/cms/product/7bde44e0-2c19-4f74-9ec9-80bed80baae2.jpg",
|
||||
"image_urls": [
|
||||
"https://cdn.grofers.com/cdn-cgi/image/f=auto,fit=scale-down,q=85,metadata=none,w=480,h=480/da/cms-assets/cms/product/7bde44e0-2c19-4f74-9ec9-80bed80baae2.jpg",
|
||||
"https://cdn.grofers.com/cdn-cgi/image/f=auto,fit=scale-down,q=85,metadata=none,w=480,h=480/da/cms-assets/cms/product/038f79074c7d436ea884d9ccc4d41c9f.jpeg"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -40463,7 +40503,10 @@
|
||||
0.001314538181759417,
|
||||
0.03696413338184357,
|
||||
-0.018342463299632072
|
||||
]
|
||||
],
|
||||
"primary_image": "https://cdn.grofers.com/cdn-cgi/image/f=auto,fit=scale-down,q=85,metadata=none,w=480,h=480/da/cms-assets/cms/product/7bde44e0-2c19-4f74-9ec9-80bed80baae2.jpg",
|
||||
"total_images": 2,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Palak",
|
||||
@@ -40472,8 +40515,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Palak. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/0/0f/Zunaid_Ahmed_Palak.jpg/960px-Zunaid_Ahmed_Palak.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"image_urls": [
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/0/0f/Zunaid_Ahmed_Palak.jpg/960px-Zunaid_Ahmed_Palak.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/8/89/Zunaid_Ahmed_Palak_at_10th_Anniversary_of_Bengali_Wikipedia%2C_30_May_2015_01.jpg/960px-Zunaid_Ahmed_Palak_at_10th_Anniversary_of_Bengali_Wikipedia%2C_30_May_2015_01.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/2/2a/Zunaid_Ahmed_Palak_at_10th_Anniversary_of_Bengali_Wikipedia%2C_30_May_2015_02.jpg/960px-Zunaid_Ahmed_Palak_at_10th_Anniversary_of_Bengali_Wikipedia%2C_30_May_2015_02.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/1/1c/Zunaid_Ahmed_Palak_at_BN_wiki_10th_Anniversary_Conference_30_May_2015_47.jpg/960px-Zunaid_Ahmed_Palak_at_BN_wiki_10th_Anniversary_Conference_30_May_2015_47.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -40877,7 +40925,10 @@
|
||||
0.026351049542427063,
|
||||
0.023857485502958298,
|
||||
-0.011563683860003948
|
||||
]
|
||||
],
|
||||
"primary_image": "https://thumb.wikimedia.org/wikipedia/commons/thumb/0/0f/Zunaid_Ahmed_Palak.jpg/960px-Zunaid_Ahmed_Palak.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Amaranth Leaves",
|
||||
@@ -40886,8 +40937,10 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Amaranth Leaves. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://live.staticflickr.com/3476/3823578948_4eef6b0a61_b.jpg",
|
||||
"image_urls": [
|
||||
"https://live.staticflickr.com/3476/3823578948_4eef6b0a61_b.jpg"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -41291,7 +41344,10 @@
|
||||
-0.009316159412264824,
|
||||
0.10120223462581635,
|
||||
0.009406316094100475
|
||||
]
|
||||
],
|
||||
"primary_image": "https://live.staticflickr.com/3476/3823578948_4eef6b0a61_b.jpg",
|
||||
"total_images": 1,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Curry Leaves",
|
||||
@@ -41300,8 +41356,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Curry Leaves. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://images.openfoodfacts.org/images/products/506/308/916/3740/front_en.3.400.jpg",
|
||||
"image_urls": [
|
||||
"https://images.openfoodfacts.org/images/products/506/308/916/3740/front_en.3.400.jpg",
|
||||
"https://images.openfoodfacts.org/images/products/502/188/500/8535/front_fr.3.400.jpg",
|
||||
"https://images.openfoodfacts.org/images/products/888/810/137/0701/front_fr.7.400.jpg",
|
||||
"https://images.openfoodfacts.org/images/products/501/768/927/3972/front_fr.3.400.jpg"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -41705,7 +41766,10 @@
|
||||
0.04047529771924019,
|
||||
0.06272368878126144,
|
||||
-0.010273406282067299
|
||||
]
|
||||
],
|
||||
"primary_image": "https://images.openfoodfacts.org/images/products/506/308/916/3740/front_en.3.400.jpg",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Coriander Leaves",
|
||||
@@ -41714,8 +41778,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Coriander Leaves. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://facts.net/wp-content/uploads/2023/07/20-facts-about-coriander-leaves-1689872594.jpg",
|
||||
"image_urls": [
|
||||
"https://facts.net/wp-content/uploads/2023/07/20-facts-about-coriander-leaves-1689872594.jpg",
|
||||
"https://www.realsimple.com/thmb/jfNmT79_U3a41-gXwNCvZfdsWso=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/coriander-vs-cilantro-Comparison-eb65b0bb42d3423b96f58bfd2f6a3e75.jpg",
|
||||
"https://images.news18.com/telugu/uploads/2023/09/corriander-169529955316x9.jpg",
|
||||
"https://snapcalorie-webflow-website.s3.us-east-2.amazonaws.com/media/food_pics_v2/coriander_leaves.jpg"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -42119,7 +42188,10 @@
|
||||
0.08500473946332932,
|
||||
0.03993428871035576,
|
||||
0.05999131500720978
|
||||
]
|
||||
],
|
||||
"primary_image": "https://facts.net/wp-content/uploads/2023/07/20-facts-about-coriander-leaves-1689872594.jpg",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Mint Leaves",
|
||||
@@ -42128,8 +42200,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Mint Leaves. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://images.openfoodfacts.org/images/products/762/753/647/5268/front_en.19.400.jpg",
|
||||
"image_urls": [
|
||||
"https://images.openfoodfacts.org/images/products/762/753/647/5268/front_en.19.400.jpg",
|
||||
"https://images.openfoodfacts.org/images/products/590/095/600/2309/front_pl.4.400.jpg",
|
||||
"https://images.openfoodfacts.org/images/products/007/067/000/0259/front_fr.4.400.jpg",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/f/f6/A_some_of_mint_leaves_in_the_jar.jpg/960px-A_some_of_mint_leaves_in_the_jar.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -42533,7 +42610,10 @@
|
||||
0.08544730395078659,
|
||||
0.05343063175678253,
|
||||
0.025364071130752563
|
||||
]
|
||||
],
|
||||
"primary_image": "https://images.openfoodfacts.org/images/products/762/753/647/5268/front_en.19.400.jpg",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Methi Leaves",
|
||||
@@ -42542,8 +42622,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Methi Leaves. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://images.tv9telugu.com/wp-content/uploads/2022/10/methi-leaves.jpg?w=1280",
|
||||
"image_urls": [
|
||||
"https://images.tv9telugu.com/wp-content/uploads/2022/10/methi-leaves.jpg?w=1280",
|
||||
"https://images.news18.com/news18marathi/uploads/2024/12/Benefits-Fenugreek-Leaves-1-2024-12-4c4aa1fc032c951b9807837841a521cb.jpg",
|
||||
"https://hindi.cdn.zeenews.com/hindi/sites/default/files/2022/11/18/1431394-methi-1-1.jpg?im=FitAndFill=(1200,900)",
|
||||
"https://hindi.cdn.zeenews.com/hindi/sites/default/files/2022/11/04/1404765-methi.jpg?im=FitAndFill=(1200,900)"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -42947,7 +43032,10 @@
|
||||
0.02809380553662777,
|
||||
0.0512373223900795,
|
||||
0.05737173929810524
|
||||
]
|
||||
],
|
||||
"primary_image": "https://images.tv9telugu.com/wp-content/uploads/2022/10/methi-leaves.jpg?w=1280",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Drumstick Leaves",
|
||||
@@ -42956,8 +43044,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Drumstick Leaves. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://feeds.abplive.com/onecms/images/uploaded-images/2022/09/17/949ef10210ba6a6697fd83f7726fb4ade901d.jpg?impolicy=abp_cdn&imwidth=720",
|
||||
"image_urls": [
|
||||
"https://feeds.abplive.com/onecms/images/uploaded-images/2022/09/17/949ef10210ba6a6697fd83f7726fb4ade901d.jpg?impolicy=abp_cdn&imwidth=720",
|
||||
"https://kannada.cdn.zeenews.com/kannada/sites/default/files/styles/zm_700x400/public/2024/01/26/372809-drumstick-leaves.jpg?itok=WfT0tfkj",
|
||||
"https://ohmyfacts.com/wp-content/uploads/2024/07/20-facts-about-drumstick-tree-leaves-1720565040.jpg",
|
||||
"https://indiadailylive.com/wp-content/uploads/2023/10/drumstick-leaves.jpg"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -43361,7 +43454,10 @@
|
||||
0.05498332157731056,
|
||||
0.09988196194171906,
|
||||
0.04283886402845383
|
||||
]
|
||||
],
|
||||
"primary_image": "https://feeds.abplive.com/onecms/images/uploaded-images/2022/09/17/949ef10210ba6a6697fd83f7726fb4ade901d.jpg?impolicy=abp_cdn&imwidth=720",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Basil Leaves",
|
||||
@@ -43370,8 +43466,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Basil Leaves. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://upload.wikimedia.org/wikipedia/commons/1/19/BANANA_CREPE_CON_BASIL_LEAVES.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled",
|
||||
"image_urls": [
|
||||
"https://upload.wikimedia.org/wikipedia/commons/1/19/BANANA_CREPE_CON_BASIL_LEAVES.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/b/bd/Basil_Leaves_on_the_top.jpg/960px-Basil_Leaves_on_the_top.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/c/cf/Basil_leaves_on_plate.JPG/960px-Basil_leaves_on_plate.JPG?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/c/c1/L_Basil.jpg/960px-L_Basil.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -43775,7 +43876,10 @@
|
||||
0.08480377495288849,
|
||||
0.052451226860284805,
|
||||
0.0035562878474593163
|
||||
]
|
||||
],
|
||||
"primary_image": "https://upload.wikimedia.org/wikipedia/commons/1/19/BANANA_CREPE_CON_BASIL_LEAVES.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Thulasi",
|
||||
@@ -43784,8 +43888,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Thulasi. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://upload.wikimedia.org/wikipedia/commons/8/8f/P.C_Thulasi_%28cropped%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled",
|
||||
"image_urls": [
|
||||
"https://upload.wikimedia.org/wikipedia/commons/8/8f/P.C_Thulasi_%28cropped%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/1/16/P.C_Thulasi_and_Reddy_Sikki_from_India_won_the_gold_medal%2C_Bruce_Mary_Alexandra%2C_Li_Man_Shan_Michelle_from_Canada_won_the_Silver_and_Lim_Ee_Von_NG_Hui_Em_from_Malaysia_won_the_bronze_medal_in_Women%E2%80%99s_Doubles_badminton_event.jpg/960px-thumbnail.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/9/9a/Thulasi_thara.JPG/960px-Thulasi_thara.JPG?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"https://upload.wikimedia.org/wikipedia/commons/5/5a/Thulasi%E0%B4%9A%E0%B5%86%E0%B4%9F%E0%B4%BF.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -44189,7 +44298,10 @@
|
||||
0.02068915218114853,
|
||||
0.10079442709684372,
|
||||
0.0011494344798848033
|
||||
]
|
||||
],
|
||||
"primary_image": "https://upload.wikimedia.org/wikipedia/commons/8/8f/P.C_Thulasi_%28cropped%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Parsley",
|
||||
@@ -44198,8 +44310,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Parsley. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://images.openfoodfacts.org/images/products/503/682/975/6342/front_en.34.400.jpg",
|
||||
"image_urls": [
|
||||
"https://images.openfoodfacts.org/images/products/503/682/975/6342/front_en.34.400.jpg",
|
||||
"https://images.openfoodfacts.org/images/products/000/002/022/6558/front_en.55.400.jpg",
|
||||
"https://images.openfoodfacts.org/images/products/000/002/041/2661/front_fr.53.400.jpg",
|
||||
"https://images.openfoodfacts.org/images/products/503/741/200/2464/front_en.3.400.jpg"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -44603,7 +44720,10 @@
|
||||
0.04025179520249367,
|
||||
0.09539943933486938,
|
||||
0.0324990414083004
|
||||
]
|
||||
],
|
||||
"primary_image": "https://images.openfoodfacts.org/images/products/503/682/975/6342/front_en.34.400.jpg",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Dill Leaves",
|
||||
@@ -44612,8 +44732,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Dill Leaves. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://images.openfoodfacts.org/images/products/405/291/600/9109/front_en.3.400.jpg",
|
||||
"image_urls": [
|
||||
"https://images.openfoodfacts.org/images/products/405/291/600/9109/front_en.3.400.jpg",
|
||||
"https://images.openfoodfacts.org/images/products/008/105/701/3502/front_en.3.400.jpg",
|
||||
"https://sowrightseeds.com/cdn/shop/products/PacketFront1_20e37d8c-3518-4b2a-8c4e-91e34f64601c_1800x1800.jpg?v=1675215709",
|
||||
"https://www.happiesthealth.com/wp-content/uploads/2023/07/dill-f.jpg"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -45017,7 +45142,10 @@
|
||||
0.0548781119287014,
|
||||
0.09910596907138824,
|
||||
0.027936136350035667
|
||||
]
|
||||
],
|
||||
"primary_image": "https://images.openfoodfacts.org/images/products/405/291/600/9109/front_en.3.400.jpg",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Sorrel Leaves",
|
||||
@@ -45026,8 +45154,12 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Sorrel Leaves. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://images.squarespace-cdn.com/content/v1/62e7a92f066fa3730dcd4604/608a21d2-7168-4227-b2c3-41d2719b1ef9/v2-dhjhz-wmuro.jpg",
|
||||
"image_urls": [
|
||||
"https://images.squarespace-cdn.com/content/v1/62e7a92f066fa3730dcd4604/608a21d2-7168-4227-b2c3-41d2719b1ef9/v2-dhjhz-wmuro.jpg",
|
||||
"https://cdn.vegetariantimes.com/wp-content/uploads/2017/01/sorrel.jpg?width=3840&auto=webp&quality=75&fit=cover",
|
||||
"https://i5.walmartimages.com/seo/200-Seeds-LARGE-LEAF-SORREL-Garden-Sorrel-Spinach-Dock-Redshank-Rumex-Acetosa-Vegetable-Seeds_eae41db6-db0b-479f-b718-cd7e9c3d2d66.7324d6754d34ee98a44b28e1ceff93fc.jpeg"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -45431,7 +45563,10 @@
|
||||
0.012254527769982815,
|
||||
0.05136796832084656,
|
||||
0.021458283066749573
|
||||
]
|
||||
],
|
||||
"primary_image": "https://images.squarespace-cdn.com/content/v1/62e7a92f066fa3730dcd4604/608a21d2-7168-4227-b2c3-41d2719b1ef9/v2-dhjhz-wmuro.jpg",
|
||||
"total_images": 3,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Agathi Keerai",
|
||||
@@ -45440,8 +45575,12 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Agathi Keerai. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiOygN8kfZ3vQZ9l3Q76f9EpHQeZ2ibgWe_0tQNsXXpNXcX-AiD_OAWrTl1FQq-EnhfjkJDOd9lIZ9R0kNNpWtPohdtZZA1L6NYwnv-48nqkm3jsjfvXNlhTAa_ZWGD1ZtadorTSjH1GhYlLod7fnDHWCDYd3W8AlAjHfHUyGV6XXjSLTyweDWKmUfw534/s1452/Agathi4.jpg",
|
||||
"image_urls": [
|
||||
"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiOygN8kfZ3vQZ9l3Q76f9EpHQeZ2ibgWe_0tQNsXXpNXcX-AiD_OAWrTl1FQq-EnhfjkJDOd9lIZ9R0kNNpWtPohdtZZA1L6NYwnv-48nqkm3jsjfvXNlhTAa_ZWGD1ZtadorTSjH1GhYlLod7fnDHWCDYd3W8AlAjHfHUyGV6XXjSLTyweDWKmUfw534/s1452/Agathi4.jpg",
|
||||
"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiP4sGlKzDU-6ypvQO3ylESSmUf2RFINoaLv6C3DYOJ55fQuL2pAJpMi1Bq82xTX5QJciK8CE0A3hong9wr5_0adqV6rfOCetR83ziBDO3bjB4V9t7zzBel1AYOZrR-iTjRxfi3N4-V0iWV/s1600/Agathikeerai+poriyal.JPG",
|
||||
"https://m.media-amazon.com/images/I/41MAfV24BXL.jpg"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -45845,7 +45984,10 @@
|
||||
-0.033807482570409775,
|
||||
0.0017142812721431255,
|
||||
0.04498672857880592
|
||||
]
|
||||
],
|
||||
"primary_image": "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiOygN8kfZ3vQZ9l3Q76f9EpHQeZ2ibgWe_0tQNsXXpNXcX-AiD_OAWrTl1FQq-EnhfjkJDOd9lIZ9R0kNNpWtPohdtZZA1L6NYwnv-48nqkm3jsjfvXNlhTAa_ZWGD1ZtadorTSjH1GhYlLod7fnDHWCDYd3W8AlAjHfHUyGV6XXjSLTyweDWKmUfw534/s1452/Agathi4.jpg",
|
||||
"total_images": 3,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Ponnanganni Keerai",
|
||||
@@ -45854,8 +45996,12 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Ponnanganni Keerai. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhNv5jXFC1JGhdDluKblp8CLBsij7qfCDhXEOZgff7kMBcZarnmcZZnidcK91Mr1SxFL-m9e68Ca-_hDlR1RJhasNMwsBwXK3e3QUppOS2d9SYXnXJxl0y8jeqvZ0COFVGozjvJntqDqUg/s1600/20170913_131941-01.jpeg",
|
||||
"image_urls": [
|
||||
"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhNv5jXFC1JGhdDluKblp8CLBsij7qfCDhXEOZgff7kMBcZarnmcZZnidcK91Mr1SxFL-m9e68Ca-_hDlR1RJhasNMwsBwXK3e3QUppOS2d9SYXnXJxl0y8jeqvZ0COFVGozjvJntqDqUg/s1600/20170913_131941-01.jpeg",
|
||||
"https://2.bp.blogspot.com/-pJf9XHgiEbk/WVx9xFMKCiI/AAAAAAAAJR8/JZolN-8cPU05Fd7I7h2_vPRkUqVTyWR6wCLcBGAs/s1600/Ponnanganni+greens+chutney.jpg",
|
||||
"https://www.kamalascorner.com/wp-content/uploads/2008/03/Ponnanganni-Keerai-Pink.jpg"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -46259,7 +46405,10 @@
|
||||
-0.02059229090809822,
|
||||
0.040050771087408066,
|
||||
0.021664783358573914
|
||||
]
|
||||
],
|
||||
"primary_image": "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhNv5jXFC1JGhdDluKblp8CLBsij7qfCDhXEOZgff7kMBcZarnmcZZnidcK91Mr1SxFL-m9e68Ca-_hDlR1RJhasNMwsBwXK3e3QUppOS2d9SYXnXJxl0y8jeqvZ0COFVGozjvJntqDqUg/s1600/20170913_131941-01.jpeg",
|
||||
"total_images": 3,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Mustard Leaves",
|
||||
@@ -46268,8 +46417,13 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Mustard Leaves. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/a/ae/Alliaria_petiolata_-_garlic_mustard_-_desc-young_foliage.jpg/960px-Alliaria_petiolata_-_garlic_mustard_-_desc-young_foliage.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"image_urls": [
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/a/ae/Alliaria_petiolata_-_garlic_mustard_-_desc-young_foliage.jpg/960px-Alliaria_petiolata_-_garlic_mustard_-_desc-young_foliage.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/0/0d/Buro_with_mustard_leaves_and_eggplant.jpg/960px-Buro_with_mustard_leaves_and_eggplant.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"https://upload.wikimedia.org/wikipedia/commons/6/60/Curly_mustard_leaves.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled",
|
||||
"https://thumb.wikimedia.org/wikipedia/commons/thumb/d/dd/Garlic_Mustard_%28Alliaria_petiolata%29_-_Kitchener%2C_Ontario.jpg/960px-Garlic_Mustard_%28Alliaria_petiolata%29_-_Kitchener%2C_Ontario.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -46673,7 +46827,10 @@
|
||||
0.03504066541790962,
|
||||
0.07110962271690369,
|
||||
0.046843890100717545
|
||||
]
|
||||
],
|
||||
"primary_image": "https://thumb.wikimedia.org/wikipedia/commons/thumb/a/ae/Alliaria_petiolata_-_garlic_mustard_-_desc-young_foliage.jpg/960px-Alliaria_petiolata_-_garlic_mustard_-_desc-young_foliage.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",
|
||||
"total_images": 4,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Spring Garlic",
|
||||
@@ -46682,8 +46839,12 @@
|
||||
"size": "Standard",
|
||||
"size_variants": [],
|
||||
"description": "Spring Garlic. Sold loose by weight or by the piece; no brand, no fixed pack size.",
|
||||
"image_url": null,
|
||||
"image_urls": [],
|
||||
"image_url": "https://images.openfoodfacts.org/images/products/506/014/505/0501/front_fr.10.400.jpg",
|
||||
"image_urls": [
|
||||
"https://images.openfoodfacts.org/images/products/506/014/505/0501/front_fr.10.400.jpg",
|
||||
"https://images.openfoodfacts.org/images/products/600/982/698/0040/front_fr.4.400.jpg",
|
||||
"https://images.openfoodfacts.org/images/products/001/380/055/6677/front_en.3.400.jpg"
|
||||
],
|
||||
"price_range": null,
|
||||
"providers": [],
|
||||
"fssai_license": null,
|
||||
@@ -47087,7 +47248,10 @@
|
||||
0.03384043276309967,
|
||||
0.010289044119417667,
|
||||
0.016713209450244904
|
||||
]
|
||||
],
|
||||
"primary_image": "https://images.openfoodfacts.org/images/products/506/014/505/0501/front_fr.10.400.jpg",
|
||||
"total_images": 3,
|
||||
"image_source": "seed_build"
|
||||
},
|
||||
{
|
||||
"product_name": "Jasmine",
|
||||
|
||||
@@ -3,26 +3,28 @@
|
||||
Reply to the seven findings dated 31 August 2026 (drop
|
||||
`8e1448e176d843d08183d387ad724f95` → run `0ed4c77b0ca14e03b1aaf6b5b77d1994`).
|
||||
|
||||
Thank you for this. It is the most useful bug report this project has had, and
|
||||
two of the seven led us to faults we had not found ourselves — one of them
|
||||
affecting rows well outside the ones you named.
|
||||
|
||||
Every figure below was measured against the same live deployment, not read off
|
||||
source. Where we disagree with a finding, the evidence is included so you can
|
||||
check it rather than take our word for it.
|
||||
|
||||
**Summary:** four items are fixed and ship in the next backend deploy. One
|
||||
(#02) was already in the API and we had failed to document it — that is our
|
||||
fault and the docs are now corrected. #01 is diagnosed, and the cause is not
|
||||
what either of us assumed. #06 is confirmed but carries a trap that means we
|
||||
should agree an approach before touching it.
|
||||
|
||||
| # | Finding | Status |
|
||||
| --- | --- | --- |
|
||||
| 01 | Pack sizes replaced between scrapes | **Diagnosed** — three causes, not one. Fix needs your input |
|
||||
| 02 | `rejected` is a bare count | **Already shipped, now documented** — plus `row` added |
|
||||
| 01 | Pack sizes replaced between scrapes | **Diagnosed** — three causes, not one. One is fixed; the other two need your input |
|
||||
| 02 | `rejected` is a bare count | **Was already in the API — our docs failed you.** `row` added |
|
||||
| 03 | Manifest brands are not catalogue keys | **Fixed** — `brand_key` published |
|
||||
| 04 | Run files carry no `from_drop` | **Fixed** |
|
||||
| 05 | Manifest carries no `source_row` | **Fixed** |
|
||||
| 06 | Duplicate brands and products | **Confirmed.** Read the trap below before we act |
|
||||
| 06 | Duplicate brands and products | **Brand merged. Stray numbers diagnosed — there are 18, not 1** |
|
||||
| 07 | No loose-produce coverage | **Fixed** — 159-row base list, and the upload path now handles produce |
|
||||
|
||||
> **Everything below ships in the next backend deploy and is not live yet.** The
|
||||
> only change already applied to the live database is the Haldiram merge in #06.
|
||||
> We will confirm when the deploy lands; please do not re-test until then.
|
||||
|
||||
---
|
||||
|
||||
## 01 — Pack sizes and names are replaced between scrapes
|
||||
@@ -31,11 +33,12 @@ You asked us to confirm whether a pack size that once existed is meant to
|
||||
survive a re-scrape. **It is not, today** — but that is only the last of three
|
||||
causes, and fixing it alone would not have saved your links.
|
||||
|
||||
### Cause 1: pack sizes are invented when a scrape does not supply them
|
||||
### Cause 1: the pack sizes were never scraped facts. Some were generated.
|
||||
|
||||
`_sizes_for()` falls back to `default_size_variants(category, name)` when a
|
||||
product declares no size. That fallback is **keyed on the resolved category**,
|
||||
and the resolved category is not stable between runs. Measured today:
|
||||
When a product declares no size, `_sizes_for()` falls back to
|
||||
`default_size_variants(category, name)`. That fallback is **keyed on the
|
||||
resolved category**, and the resolved category is not stable between runs.
|
||||
Measured today:
|
||||
|
||||
```
|
||||
category "Snacks" -> ['55g', '150g', '200g']
|
||||
@@ -46,18 +49,25 @@ category "Breakfast Cereal" -> ['250g', '500g', '1kg']
|
||||
Now compare your table. Cheetos id 25 is **55g** — the *Snacks* set. Ids 26 and
|
||||
27 are **100g** and **250g** — the *unresolved* set. The same product was
|
||||
ingested once with its category resolved and once without, and produced two
|
||||
disjoint sets of pack sizes. Your Cheerios ids (100g, 250g, 500g) sit across the
|
||||
Breakfast Cereal set and the unresolved set the same way.
|
||||
disjoint sets of pack sizes. Your Cheerios ids sit across the Breakfast Cereal
|
||||
set and the unresolved set the same way.
|
||||
|
||||
So the pack sizes were never scraped facts that changed. Some of them were
|
||||
generated, and the generator's input moved.
|
||||
So a 100 g bag and a 250 g bag are indeed different SKUs, and you are right not
|
||||
to re-point one at the other — but in these cases neither number came off a
|
||||
pack. They were both guesses, from two different guesses about the category.
|
||||
|
||||
**This one is now fixed for the class of product where it does most damage.**
|
||||
Unbranded and loose goods no longer receive invented sizes at all (see #07). For
|
||||
branded packaged goods the fallback still runs, because a brand really does sell
|
||||
a small/medium/large range and omitting it entirely loses more than it saves.
|
||||
That is the part we would like your view on — see *What we need from you*.
|
||||
|
||||
### Cause 2: the product name gains or loses a brand prefix
|
||||
|
||||
Your own #06 has the evidence: `Hot Heads 30g` became `Nestle Hot Heads`, and
|
||||
`PepsiCo Kurkure Masala Munch 90g` coexists with `Kurkure Masala Munch 90g`.
|
||||
`image_id` is derived from the name, so a prefix appearing or disappearing moves
|
||||
the id even when the product is identical.
|
||||
the id even when the product is identical. Still open; see #06.
|
||||
|
||||
### Cause 3: the write then deletes whatever is not in the new set
|
||||
|
||||
@@ -71,54 +81,55 @@ re-scrape**, which matches your finding that zero of eleven could be repaired.
|
||||
|
||||
### Not the upload path
|
||||
|
||||
Worth stating plainly, because it affects how much you need to worry: the
|
||||
Worth stating plainly, because it changes how much you need to worry: the
|
||||
**upload** path — everything reached through `POST /api/uploads/catalog` — uses
|
||||
`cleanup=False` and has always done so. A sheet you send can never delete a row
|
||||
it does not mention. The deletions came from brand scraping only.
|
||||
|
||||
### What we need from you
|
||||
|
||||
The real fix is to stop causes 1 and 2 (do not invent sizes for a product
|
||||
already in the catalogue; settle the naming convention), and to soft-retire
|
||||
rather than delete for cause 3. That third part changes how the production
|
||||
catalogue is written and we would rather agree it with you than spring it:
|
||||
|
||||
- Would a `retired_at` timestamp plus exclusion from the default read work for
|
||||
you, instead of the row being deleted? That preserves the `image_id` so your
|
||||
stored link resolves to something, and lets us give you the `superseded_by`
|
||||
and per-run changelog you asked for.
|
||||
- If so, do you want retired rows visible through an explicit query, or gone
|
||||
from the API entirely?
|
||||
`cleanup=False` and always has. A sheet you send can never delete a row it does
|
||||
not mention. The deletions came from brand scraping only.
|
||||
|
||||
### Your two direct questions
|
||||
|
||||
**Is `image_id` stable across re-scrapes for a product whose name and pack size
|
||||
have not changed?** Yes. It is a pure deterministic function of brand, product
|
||||
name and pack size, with no clock, counter or run id in it. Verified:
|
||||
name and pack size, with no clock, counter or run id in it:
|
||||
|
||||
```
|
||||
build_image_id('pepsico', 'Cheetos Chips', '100g') -> pepsico_cheetos_chips_100g
|
||||
```
|
||||
|
||||
Storing it rather than our row id is the right call and we have documented the
|
||||
guarantee so it does not quietly change. **The caveat is #06:** the guarantee is
|
||||
only as good as the stability of the name, and inconsistent brand prefixing
|
||||
breaks exactly that.
|
||||
Storing it rather than our row id is the right call, and we have now documented
|
||||
the guarantee in `INGESTION_API.md` so it cannot quietly change. **The caveat is
|
||||
#06:** the guarantee is only as good as the stability of the name, and
|
||||
inconsistent brand prefixing breaks exactly that.
|
||||
|
||||
**Do you want to know when a product is dropped or renamed?** Yes, and we agree
|
||||
it should exist. It falls out of the retirement model above rather than being a
|
||||
separate feature, which is why we would like to settle that first.
|
||||
it should exist. It falls out of the retirement model below rather than being a
|
||||
separate feature.
|
||||
|
||||
### What we need from you
|
||||
|
||||
- Would a `retired_at` timestamp plus exclusion from the default read work
|
||||
instead of the row being deleted? That preserves the `image_id` so your stored
|
||||
link resolves to *something*, and gives us somewhere to hang the
|
||||
`superseded_by` and per-run changelog you asked for.
|
||||
- If so: should retired rows be reachable through an explicit query, or absent
|
||||
from the API entirely?
|
||||
- On cause 1: would you rather we **stopped inventing sizes altogether** for
|
||||
branded goods too? It would shrink the catalogue and lose some genuine
|
||||
variants, but every remaining row would be a size somebody actually saw on a
|
||||
pack. We can go either way and would rather match how you consume it.
|
||||
|
||||
---
|
||||
|
||||
## 02 — `rejected` is a count with no reason
|
||||
|
||||
**This is our documentation failure, not a missing feature.** `rejections[]` has
|
||||
been in every response — single-batch read and list endpoint both, since
|
||||
`to_out(slim=True)` strips only `products` — carrying `product_name`, `size` and
|
||||
`reason` per refused row. It was absent from `INGESTION_API.md`, which documents
|
||||
`"rejected": 0` and never mentions the array, so there was no way for you to
|
||||
know it was there. Sorry — that is a straightforwardly bad docs bug.
|
||||
**This one is our documentation failing you, not a missing feature, and we are
|
||||
sorry for the time it cost.** `rejections[]` has been in every response — the
|
||||
single-batch read and the list endpoint both, since `to_out(slim=True)` strips
|
||||
only `products` — carrying `product_name`, `size` and `reason` per refused row.
|
||||
|
||||
It was absent from `INGESTION_API.md`, which documents `"rejected": 0` and never
|
||||
mentions the array, so there was no way for you to know it was there. You were
|
||||
diffing 19 against 17 because our docs told you that was all you had.
|
||||
|
||||
The genuine gap was the row number, which is now added:
|
||||
|
||||
@@ -133,6 +144,8 @@ The genuine gap was the row number, which is now added:
|
||||
convention as the `422` responses, so it matches what the operator sees on
|
||||
screen. `null` only when the row cannot be located. Capped at 50 per file.
|
||||
|
||||
The array is now documented, with a field table.
|
||||
|
||||
---
|
||||
|
||||
## 03 — Manifest brands are not catalogue keys
|
||||
@@ -143,13 +156,13 @@ Fixed. Every entry in `products[]` now carries `brand_key` beside `brand`:
|
||||
{ "brand": "24 Mantra", "brand_key": "24_mantra", ... }
|
||||
```
|
||||
|
||||
This is generated by the same function the storage layer uses to name the table,
|
||||
It is generated by the same function the storage layer uses to name the table,
|
||||
so it cannot drift from the key the catalogue is actually addressed by. Your
|
||||
normalisation is correct as far as we can tell, but it is a guess, and the
|
||||
failure mode is silent — a wrong key finds nothing rather than erroring.
|
||||
|
||||
Thank you for degrading rather than failing the batch on an unreadable brand;
|
||||
that is the right behaviour and we should have done it on our side too.
|
||||
Thank you for making a single unreadable brand degrade rather than fail the
|
||||
batch. That is the right behaviour and we should have done it on our side too.
|
||||
|
||||
---
|
||||
|
||||
@@ -169,8 +182,8 @@ from — the exact inverse of `released_to`:
|
||||
```
|
||||
|
||||
`null` for a file that went straight into a run without sitting in an inbox —
|
||||
which, under `UPLOAD_AUTORUN=true`, is every file you send, because the id you
|
||||
are handed is already the run.
|
||||
which, under the current `UPLOAD_AUTORUN=true`, is every file you send, because
|
||||
the id you are handed is already the run.
|
||||
|
||||
There is a test in our suite that stages two drops from different senders both
|
||||
named `products.csv` and asserts they are distinguishable, so the collision you
|
||||
@@ -193,43 +206,86 @@ set difference rather than a name-matching heuristic.
|
||||
|
||||
## 06 — Duplicate brands, duplicate products, stray names
|
||||
|
||||
Confirmed against the live database today: **55 brand tables, 1,414 products**
|
||||
(you counted 1,614; the difference is a day of drift plus, we think, your count
|
||||
including rejected rows — worth reconciling if it matters).
|
||||
### The brand split — merged
|
||||
|
||||
`haldiram` (1 product) is gone; `haldirams` (2) is the survivor.
|
||||
|
||||
The split was not a typo. **Nothing in the system knew the two spellings were
|
||||
one brand** — neither was in the alias map, so each resolved to itself and every
|
||||
upload built whichever table its sheet happened to name. Merging the rows alone
|
||||
would have fixed nothing: the next sheet spelling it without the "s" would
|
||||
rebuild the table. The alias is in, so `Haldiram`, `haldiram`, `HALDIRAM` and
|
||||
`Haldiram's` all now resolve to `haldirams`.
|
||||
|
||||
While merging we found the singular row was a corrupted duplicate of one already
|
||||
in the plural table — same product, but with a stray `45` in the name, a raw
|
||||
category id, and a bare-number size. We kept the clean row and carried across
|
||||
the one thing the corrupted row had that it lacked: a newer price.
|
||||
|
||||
We also found, and corrected, something you could not have seen: **both
|
||||
`haldirams` rows were carrying Lion Dates' FSSAI licence** (`10012042000244`,
|
||||
the number on all 21 Lion Dates products) rather than Haldiram's own
|
||||
(`10012011000140`). That is a regulatory identifier on the wrong manufacturer's
|
||||
product, and it is fixed in the database and the seed file.
|
||||
|
||||
### The stray number — there are 18 of them, and we know what it is
|
||||
|
||||
You found `Lays Classic Salted 52g 150` and asked us to check for the pattern
|
||||
elsewhere. **It affects 18 products across at least seven brands:**
|
||||
|
||||
```
|
||||
haldiram 1 britannia 6
|
||||
haldirams 2 parle 3 against hindustan_unilever 443
|
||||
patanjali 3
|
||||
Aashirvaad Shudh Chakki Atta 5kg 40 size_variants ['40'] price 299
|
||||
Lays Classic Salted 52g 150 size_variants ['150'] price 21
|
||||
Britannia Good Day Cashew 200g 60 size_variants ['60'] price 52
|
||||
Coca-Cola 750ml 72 size_variants ['72'] price 42
|
||||
Dove Cream Beauty Bar 100g 64 size_variants ['64'] price 76
|
||||
Horlicks Classic Malt 500g 18 size_variants ['18'] price 289
|
||||
... 12 more
|
||||
```
|
||||
|
||||
So: the split brand is real, and Britannia/Parle/Patanjali do look like scrapes
|
||||
that stopped part-way rather than genuinely small brands. We will re-run those
|
||||
three.
|
||||
The number is **not** a price — 40 against ₹299, 150 against ₹21. It is the
|
||||
**case-pack count**: how many units come in a carton. A sheet's "Quantity"
|
||||
column was mapped to the pack-size field, the bare number became the size, and
|
||||
`_to_storage_row` then appended it to the product name.
|
||||
|
||||
### The trap, which is why we have not just fixed this
|
||||
**The cause is already fixed**, on two layers, both verified today:
|
||||
|
||||
**De-duplicating the PepsiCo pairs means renaming a product, and `image_id` is
|
||||
derived from the name.** Renaming `PepsiCo Kurkure Masala Munch 90g` to
|
||||
`Kurkure Masala Munch 90g` does not merge the two rows — it mints a third id and
|
||||
breaks any link pointing at either of the first two. You have just migrated onto
|
||||
storing `image_id`. A well-meant cleanup on our side would re-break exactly what
|
||||
you have finished repairing.
|
||||
- The column mapper no longer maps a bare `Quantity` column to pack size. A
|
||||
sheet with both `Pack Size` and `Quantity` now binds only `Pack Size`.
|
||||
- `_sizes_for()` discards a unitless number and records why:
|
||||
`ignored pack size '150': a number with no unit is a quantity, not a size`.
|
||||
|
||||
The same applies to stripping the stray `150` from `Lays Classic Salted 52g 150`.
|
||||
So no new rows can acquire this. The 18 existing ones are legacy damage and we
|
||||
will repair them — see the request below.
|
||||
|
||||
So before we touch it we would like to agree:
|
||||
### The PepsiCo duplicate pairs — we need one decision from you first
|
||||
|
||||
Both pairs are still there (ids 661/730 and 662/731). We have deliberately not
|
||||
touched them, because **de-duplicating means renaming, and `image_id` is derived
|
||||
from the name.** Renaming `PepsiCo Kurkure Masala Munch 90g` to
|
||||
`Kurkure Masala Munch 90g` does not merge the two rows — it mints a *third* id
|
||||
and breaks any link pointing at either of the first two. You have just migrated
|
||||
onto storing `image_id`; a well-meant cleanup on our side would re-break exactly
|
||||
what you have finished repairing. The same applies to stripping the stray
|
||||
numbers.
|
||||
|
||||
So, three things to agree before we act:
|
||||
|
||||
1. **Which convention wins** — brand prefix in the product name, or not? We have
|
||||
no strong preference; we care only that it is one of them. Our lean is
|
||||
*without* the prefix, since the brand is already a column.
|
||||
2. **How the merge is communicated.** If we can give you the old-id →
|
||||
new-id mapping for every row we touch, in advance, does that let you
|
||||
re-point rather than clear? That is straightforward for us to produce.
|
||||
no strong preference and care only that it is one of them. Our lean is
|
||||
*without*, since the brand is already a column.
|
||||
2. **Would an old-id → new-id mapping, delivered in advance for every row we
|
||||
touch, let you re-point rather than clear?** That is straightforward for us
|
||||
to produce and would cover both the 18 stray-number rows and the PepsiCo
|
||||
pairs.
|
||||
3. **Timing**, so it lands in one pass rather than trickling.
|
||||
|
||||
`haldiram` → `haldirams` is a three-row merge and much lower risk; we can do
|
||||
that one immediately if you would rather not wait for the rest.
|
||||
### Brand coverage
|
||||
|
||||
Confirmed from the live database: **55 tables, 1,414 products**. You counted
|
||||
1,614 — worth reconciling, but the shape matches. And yes: **Britannia (6),
|
||||
Parle (3) and Patanjali (3) are incomplete scrapes, not small brands.** We will
|
||||
re-run those three.
|
||||
|
||||
---
|
||||
|
||||
@@ -241,7 +297,7 @@ because it was not only a coverage gap.
|
||||
### What was actually happening
|
||||
|
||||
Produce rows were not rejected. They were **misfiled**, which is worse. The
|
||||
brand fallback takes the first word of the name and then whole-word matches it
|
||||
brand fallback takes the first word of the name and whole-word matches it
|
||||
against our alias map:
|
||||
|
||||
```
|
||||
@@ -249,12 +305,12 @@ Apple -> brand "Apple" -> junk table brand_apple
|
||||
Tomato -> brand "Tomato" -> junk table brand_tomato
|
||||
Bitter Gourd -> brand "Bitter" -> junk table brand_bitter
|
||||
Curry Leaves -> brand "Curry" -> junk table brand_curry
|
||||
Red Rose -> brand "Red" -> brand_brooke_bond <--
|
||||
Red Rose -> brand "Red" -> brand_brooke_bond <--
|
||||
```
|
||||
|
||||
That last one is not a typo. A rose was being written into the Brooke Bond tea
|
||||
catalogue, and our enrichment then stamps that brand's real FSSAI licence number
|
||||
onto the row. Your 139 hand-typed products were the visible symptom; this was
|
||||
catalogue, and our enrichment then stamps that brand's real FSSAI licence onto
|
||||
the row. Your 139 hand-typed products were the visible symptom; this was
|
||||
underneath it.
|
||||
|
||||
### What now happens
|
||||
@@ -265,28 +321,38 @@ them. Fruit, vegetables, greens, herbs, flowers, fish, eggs and loose dairy are
|
||||
covered, alongside the pulses, grains, spices, oils and sugar that already were.
|
||||
|
||||
Five categories were added — Fruits & Vegetables, Fresh Herbs & Greens, Flowers,
|
||||
Fish & Seafood, Eggs — with HSN codes and a 0% GST rate, since unprocessed
|
||||
produce is nil-rated rather than reduced-rate.
|
||||
Fish & Seafood, Eggs — with HSN codes at 0% GST, since unprocessed produce is
|
||||
nil-rated rather than reduced-rate.
|
||||
|
||||
Merchant misspellings from your own data are handled: `Bitter guard`,
|
||||
`Bottle ground`, `Ladies Finger` all resolve.
|
||||
Misspellings from your own audit are handled: `Bitter guard`, `Bottle ground`
|
||||
and `Ladies Finger` all resolve.
|
||||
|
||||
An uploaded produce row also now keeps only what the sheet actually said. Name,
|
||||
weight and price are stored; HSN, SKU, barcode, FSSAI and description are left
|
||||
null rather than invented, and **no pack sizes are generated** — which is cause
|
||||
1 of your finding #01, kept out of this table from the start.
|
||||
|
||||
### The base list
|
||||
|
||||
**159 rows**, in the shape you asked for: name and category only, no brand, no
|
||||
pack size, no price. Fruit (42), vegetables (53), greens and herbs (17), flowers
|
||||
(14), fish and seafood (15), loose dairy (13), eggs (5). It includes the specific
|
||||
items your audit listed — Jasmine, Lotus, Red Rose, Thulasi, Drumstick, Curry
|
||||
Leaves, the four banana varieties, Tuna, Mackerel.
|
||||
**159 rows**, in the shape you asked for: name and category, no brand, no pack
|
||||
size, no price.
|
||||
|
||||
Each row carries a `search_query` embedding, so these are reachable through
|
||||
semantic search and not just exact match. The list is hand-authored rather than
|
||||
scraped, so it is not subject to any of #01.
|
||||
```
|
||||
Fruits & Vegetables 95 Fish & Seafood 15
|
||||
Fresh Herbs & Greens 17 Dairy (loose) 13
|
||||
Flowers 14 Eggs 5
|
||||
```
|
||||
|
||||
**Images are not included yet.** You asked for name and image; we have shipped
|
||||
the names. Sourcing 159 licensable produce photographs is a separate piece of
|
||||
work and we did not want to hold the list for it — tell us if the list is not
|
||||
useful to you without them and we will prioritise accordingly.
|
||||
It includes the specific items your audit listed — Jasmine, Lotus, Red Rose,
|
||||
Thulasi, Drumstick, Curry Leaves, the four banana varieties, Tuna, Mackerel.
|
||||
Each row carries a search embedding, so these are reachable through semantic
|
||||
search and not just exact match. The list is hand-authored rather than scraped,
|
||||
so none of #01 applies to it.
|
||||
|
||||
**Images: 112 of the 159 rows (70%) currently have one** — all of the fruit,
|
||||
vegetables, greens and herbs. Flowers, fish, loose dairy and eggs are still
|
||||
name-only; we stopped the fetch part-way and will finish it. Tell us if the list
|
||||
is more useful to you complete-but-later or partial-but-now.
|
||||
|
||||
### One limitation worth knowing
|
||||
|
||||
@@ -295,11 +361,11 @@ means "this is a brand". So place-qualified produce — `Salem Mango`,
|
||||
`Mysore Banana`, `Jammu Apple`, all real strings from your Ragul Stores data —
|
||||
still reads as branded, because `Mysore` is also a real brand (Mysore Sandal).
|
||||
|
||||
The workaround is already in the pipeline: **if the sheet has a Brand column and
|
||||
leaves the cell empty, we believe it** and file the row under Own Products
|
||||
There is a workaround already in the pipeline: **if the sheet has a Brand column
|
||||
and leaves the cell empty, we believe it** and file the row under Own Products
|
||||
regardless of the name. If your merchants' sheets carry an empty brand column,
|
||||
those rows will land correctly. If they carry no brand column at all, the
|
||||
name-based test is what applies.
|
||||
those rows land correctly. If they carry no brand column at all, the name-based
|
||||
test applies.
|
||||
|
||||
We would rather be conservative here. Collapsing a real regional brand into the
|
||||
unbranded bucket is much harder to undo than a mango sitting in the wrong table.
|
||||
@@ -311,13 +377,28 @@ unbranded bucket is much harder to undo than a mango sitting in the wrong table.
|
||||
- The produce lexicon was run against **all 1,414 products in the live
|
||||
catalogue** and against all **231 brand aliases**: zero reclassifications in
|
||||
either. That check is now a test, so it runs on every change.
|
||||
- It also surfaced a pre-existing bug we would not otherwise have found: our
|
||||
pack-size stripper was eating the word after a number, so
|
||||
`24 Mantra Organic Moong Dal 500g` lost its brand entirely and was being filed
|
||||
as an unbranded commodity. **Every brand whose name starts with a digit hit
|
||||
this.** "24 Mantra" appears on your finding-03 list, which is how we noticed.
|
||||
Fixed.
|
||||
- It surfaced a pre-existing bug we would not otherwise have found: our
|
||||
pack-size stripper was eating the word *after* a number, so
|
||||
`24 Mantra Organic Moong Dal 500g` lost its brand entirely and was filed as an
|
||||
unbranded commodity. **Every brand whose name starts with a digit hit this.**
|
||||
"24 Mantra" is on your finding-03 list, which is how we noticed. Fixed.
|
||||
- All 159 seeded rows were checked to classify identically to how an uploaded
|
||||
copy of the same name would, so a grocer typing "Tomato" lands on the seeded
|
||||
row instead of creating a second one.
|
||||
- Full suite: **1,054 tests passing.**
|
||||
- Full suite: **1,108 tests passing.**
|
||||
|
||||
## Two things you did not ask about, but should know
|
||||
|
||||
**Barcodes.** We do not generate them; a product carries one only if the sheet
|
||||
supplied it. 95 of our 1,414 products have one — and checking them against Open
|
||||
Food Facts, **33 are attached to the wrong product** (`Lion Dates Powder` is
|
||||
stored under a barcode Open Food Facts holds as a Dutch confection) and a
|
||||
further 38 are not valid GTINs at all. If you join on barcode anywhere, treat
|
||||
ours as unreliable until we have cleaned them. The lookup that produced them has
|
||||
since been tightened.
|
||||
|
||||
**Nutrition provenance.** Rows in `nutrition_facts` sourced from Open Food Facts
|
||||
were matched by *name*, with confidences as low as 0.32. We have built an exact
|
||||
barcode-keyed lookup to replace that, but given the barcode quality above it can
|
||||
currently upgrade only a handful of rows. Treat low-confidence nutrition as
|
||||
indicative, not authoritative.
|
||||
|
||||
295
scripts/backfill_nutrition_from_barcodes.py
Normal file
295
scripts/backfill_nutrition_from_barcodes.py
Normal file
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Upgrade nutrition data from a fuzzy name match to an exact barcode match.
|
||||
|
||||
WHY
|
||||
---
|
||||
Every Open Food Facts call in this project searches by brand and product name
|
||||
and then scores whatever comes back - `nutrition_data_service._search_openfoodfacts`,
|
||||
the barcode cascade's own `OpenFoodFactsSource.search`, and `image_search`. That
|
||||
is a guess, and the stored evidence says so: OFF-sourced rows in
|
||||
`nutrition_facts` carry match confidences as low as 0.32, which is exactly
|
||||
`MIN_MATCH_CONFIDENCE`.
|
||||
|
||||
For the products that carry a barcode we can do better. The barcode is the
|
||||
identifier printed on the pack, so `/api/v2/product/{code}` returns that product
|
||||
or nothing. This walks the catalogue's barcoded rows and rewrites their
|
||||
nutrition from the exact record.
|
||||
|
||||
WHAT IT WILL AND WILL NOT TOUCH
|
||||
-------------------------------
|
||||
It writes to `nutrition_facts` and to NOTHING else. No product name, price,
|
||||
image, category or barcode in any brand table is modified. The invalid barcodes
|
||||
it finds are reported, not repaired.
|
||||
|
||||
It also refuses to overwrite a human's work: `upsert_nutrition_facts` is an
|
||||
ON CONFLICT ... DO UPDATE, so a row whose existing `data_source` is `manual` or
|
||||
`excel_upload` is skipped. Replacing a 0.32 name-match with an exact barcode
|
||||
match is the point of this script; replacing something a person typed is not.
|
||||
|
||||
THE THING TO UNDERSTAND BEFORE READING THE OUTPUT
|
||||
-------------------------------------------------
|
||||
The LOOKUP is exact. The STORED BARCODE is not. Measured across the catalogue,
|
||||
a quarter of the records found this way described a different product, because
|
||||
the barcode on our row was wrong:
|
||||
|
||||
Aachi Chicken Masala 50g -> OFF "Chicken Kabab/65 Masala"
|
||||
Tata Tea Gold 500g -> OFF "Tata Tea Gold Care"
|
||||
Lion Dates Powder 100g -> OFF "PEPER NOTEN"
|
||||
|
||||
So every record still passes `matching.is_match`, and the report below prints
|
||||
the name-similarity score for every candidate - accepted or not - because no
|
||||
threshold cleanly separates the two groups (two products tie at 0.773 with
|
||||
opposite verdicts). Read the SKIPPED list: it is a list of barcodes that are
|
||||
probably wrong in OUR catalogue.
|
||||
|
||||
Usage:
|
||||
|
||||
python -m scripts.backfill_nutrition_from_barcodes # dry run
|
||||
python -m scripts.backfill_nutrition_from_barcodes --apply
|
||||
python -m scripts.backfill_nutrition_from_barcodes --min-similarity 0.7
|
||||
|
||||
`--dry-run` is the default and `--apply` must be explicit: this writes to
|
||||
whatever database `backend/.env` points at, which is production. The target host
|
||||
is printed on startup.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.infrastructure.settings import DB_HOST, DB_NAME
|
||||
from app.services.enrichment.barcode.matching import is_match, name_similarity
|
||||
from app.services.enrichment.barcode.models import BarcodeCandidate
|
||||
from app.services.enrichment.barcode.sources.open_food_facts import (
|
||||
fetch_product_by_barcode,
|
||||
)
|
||||
from app.services.nutrition_data_service import (
|
||||
BARCODE_MIN_NAME_SIMILARITY,
|
||||
fetch_verified_nutrition_by_barcode,
|
||||
)
|
||||
from app.services.nutrition_db import get_nutrition_facts, upsert_nutrition_facts
|
||||
from app.services.vector_store import _connect, display_name_for_suffix
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger("backfill_nutrition")
|
||||
|
||||
# Courtesy gap between calls to a free community API.
|
||||
PAUSE_SECONDS = 0.35
|
||||
|
||||
# data_source values that mean "a person put this here". Never overwritten.
|
||||
HUMAN_SOURCES = {"manual", "excel_upload"}
|
||||
|
||||
# A GTIN is 8, 12, 13 or 14 digits. Anything else in the barcode column is not a
|
||||
# barcode - the catalogue holds "8900000000000.0" (a float that survived an
|
||||
# Excel import) and several 8-digit codes attached to three different pack sizes
|
||||
# at once. Reported rather than looked up; a bad code cannot match anything.
|
||||
_GTIN = re.compile(r"^\d{8}$|^\d{12,14}$")
|
||||
|
||||
_SIZE_IN_NAME = re.compile(r"(\d+(?:[.,]\d+)?\s*(?:kg|g|gm|gms|ml|l|ltr))\b", re.I)
|
||||
|
||||
|
||||
def _catalogue_rows() -> List[Dict[str, Any]]:
|
||||
"""Every catalogue row that carries a barcode.
|
||||
|
||||
Columns are read defensively: `_ensure_columns` adds them lazily, so an
|
||||
older brand table can be missing `size_variants` entirely - which is a
|
||||
crash, not a warning, if you SELECT it blindly.
|
||||
"""
|
||||
conn = _connect()
|
||||
if conn is None:
|
||||
return []
|
||||
rows: List[Dict[str, Any]] = []
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema='public' AND table_name LIKE 'brand_%' "
|
||||
"ORDER BY table_name"
|
||||
)
|
||||
tables = [r[0] for r in cur.fetchall()]
|
||||
for table in tables:
|
||||
cur.execute(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name=%s", (table,))
|
||||
cols = {r[0] for r in cur.fetchall()}
|
||||
if "barcode" not in cols:
|
||||
continue
|
||||
select = "product_name,barcode,image_id,category"
|
||||
if "size_variants" in cols:
|
||||
select += ",size_variants"
|
||||
cur.execute(
|
||||
f"SELECT {select} FROM {table} "
|
||||
f"WHERE barcode IS NOT NULL AND barcode <> ''")
|
||||
for record in cur.fetchall():
|
||||
name, barcode, image_id, category = record[:4]
|
||||
sizes = record[4] if len(record) > 4 else None
|
||||
match = _SIZE_IN_NAME.search(name or "")
|
||||
size = (sizes[0] if sizes else "") or (match.group(1) if match else "")
|
||||
rows.append({
|
||||
"table": table,
|
||||
"brand": display_name_for_suffix(table[len("brand_"):]),
|
||||
"product_name": name,
|
||||
"barcode": str(barcode).strip(),
|
||||
"image_id": image_id,
|
||||
"category": category or "",
|
||||
"size": size,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--apply", action="store_true",
|
||||
help="commit the changes (default is a dry run)")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="explicit no-op; this is already the default")
|
||||
parser.add_argument("--min-similarity", type=float,
|
||||
default=BARCODE_MIN_NAME_SIMILARITY,
|
||||
help=f"name-similarity floor (default {BARCODE_MIN_NAME_SIMILARITY})")
|
||||
args = parser.parse_args()
|
||||
apply = args.apply and not args.dry_run
|
||||
|
||||
logger.info("Target database: %s / %s", DB_HOST, DB_NAME)
|
||||
logger.info("Mode: %s", "APPLY - this writes to nutrition_facts"
|
||||
if apply else "DRY RUN - nothing is written")
|
||||
logger.info("Name-similarity floor: %.2f", args.min_similarity)
|
||||
|
||||
rows = _catalogue_rows()
|
||||
if not rows:
|
||||
logger.error("No barcoded rows found (or no database connection).")
|
||||
return 1
|
||||
logger.info("")
|
||||
logger.info("%d catalogue row(s) carry a barcode.", len(rows))
|
||||
|
||||
invalid: List[Dict] = []
|
||||
lookups: List[Dict] = []
|
||||
for row in rows:
|
||||
(invalid if not _GTIN.match(row["barcode"]) else lookups).append(row)
|
||||
|
||||
if invalid:
|
||||
logger.info("")
|
||||
logger.info("NOT A VALID GTIN - skipped, and wrong in the catalogue "
|
||||
"rather than wrong here (%d):", len(invalid))
|
||||
for row in invalid:
|
||||
logger.info(" %-18s %s", row["barcode"], row["product_name"][:48])
|
||||
|
||||
written = skipped_human = not_in_off = no_nutriments = 0
|
||||
accepted: List[str] = []
|
||||
rejected: List[str] = []
|
||||
thin: List[str] = []
|
||||
|
||||
logger.info("")
|
||||
logger.info("Looking up %d barcode(s) ...", len(lookups))
|
||||
for i, row in enumerate(lookups, start=1):
|
||||
existing = get_nutrition_facts(row["brand"], row["image_id"]) or {}
|
||||
if (existing.get("data_source") or "") in HUMAN_SOURCES:
|
||||
skipped_human += 1
|
||||
continue
|
||||
|
||||
product = fetch_product_by_barcode(row["barcode"])
|
||||
if not product:
|
||||
not_in_off += 1
|
||||
time.sleep(PAUSE_SECONDS)
|
||||
continue
|
||||
|
||||
off_name = product.get("product_name") or ""
|
||||
similarity = name_similarity(off_name, row["product_name"])
|
||||
line = (f"{similarity:5.3f} {row['product_name'][:36]:38} "
|
||||
f"-> {off_name[:36]}")
|
||||
|
||||
# The gate is run HERE, separately, so the report can tell two very
|
||||
# different outcomes apart. Deciding it from the service's
|
||||
# "unavailable" alone conflated them, and the first version of this
|
||||
# report accused a dozen perfectly good barcodes of being wrong when
|
||||
# the real answer was that Open Food Facts holds a near-empty record
|
||||
# for them. One is our data to fix; the other is nobody's fault.
|
||||
candidate = BarcodeCandidate(
|
||||
barcode=row["barcode"], source_name="Open Food Facts",
|
||||
candidate_title=off_name,
|
||||
candidate_brand=product.get("brands") or "",
|
||||
candidate_size=product.get("quantity") or "",
|
||||
)
|
||||
matched, _sim = is_match(candidate, row["brand"], row["product_name"],
|
||||
row["size"], min_name_similarity=args.min_similarity)
|
||||
if not matched:
|
||||
rejected.append(line)
|
||||
time.sleep(PAUSE_SECONDS)
|
||||
continue
|
||||
|
||||
facts = fetch_verified_nutrition_by_barcode(
|
||||
row["barcode"], row["brand"], row["product_name"],
|
||||
row["size"], row["category"],
|
||||
min_name_similarity=args.min_similarity,
|
||||
)
|
||||
if facts.get("data_status") == "unavailable":
|
||||
# Gate passed, so this IS our product - OFF simply has no usable
|
||||
# numbers for it. Nothing to fix on either side.
|
||||
no_nutriments += 1
|
||||
thin.append(line)
|
||||
else:
|
||||
accepted.append(line)
|
||||
if apply:
|
||||
facts.update({
|
||||
"brand": row["brand"],
|
||||
"image_id": row["image_id"],
|
||||
"product_name": row["product_name"],
|
||||
"category": row["category"],
|
||||
})
|
||||
if upsert_nutrition_facts(facts):
|
||||
written += 1
|
||||
else:
|
||||
written += 1
|
||||
|
||||
if i % 25 == 0:
|
||||
logger.info(" %d/%d", i, len(lookups))
|
||||
time.sleep(PAUSE_SECONDS)
|
||||
|
||||
# ---- the report --------------------------------------------------------
|
||||
logger.info("")
|
||||
logger.info("ACCEPTED (%d) - barcode found AND the record is our product:",
|
||||
len(accepted))
|
||||
for line in sorted(accepted, reverse=True):
|
||||
logger.info(" %s", line)
|
||||
|
||||
if thin:
|
||||
logger.info("")
|
||||
logger.info("MATCHED BUT EMPTY (%d) - the right product, but Open Food "
|
||||
"Facts holds no usable nutrient values. Nothing wrong with "
|
||||
"our barcode:", len(thin))
|
||||
for line in sorted(thin, reverse=True):
|
||||
logger.info(" %s", line)
|
||||
|
||||
logger.info("")
|
||||
logger.info("SKIPPED (%d) - OFF knows the barcode, but as a different "
|
||||
"product. OUR barcode is the suspect one:", len(rejected))
|
||||
for line in sorted(rejected, reverse=True):
|
||||
logger.info(" %s", line)
|
||||
|
||||
logger.info("")
|
||||
logger.info(" barcoded rows %d", len(rows))
|
||||
logger.info(" not a valid GTIN %d", len(invalid))
|
||||
logger.info(" human-entered, kept %d", skipped_human)
|
||||
logger.info(" not in Open Food Facts %d", not_in_off)
|
||||
logger.info(" matched but empty %d", no_nutriments)
|
||||
logger.info(" found, wrong product %d", len(rejected))
|
||||
logger.info(" %s %d", "WRITTEN " if apply else "would write ", written)
|
||||
|
||||
logger.info("")
|
||||
if apply:
|
||||
logger.info("Done. nutrition_facts updated; no brand table was touched.")
|
||||
else:
|
||||
logger.info("Dry run - nothing written. Re-run with --apply to commit.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
185
tests/test_barcode_enrichment_guards.py
Normal file
185
tests/test_barcode_enrichment_guards.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Two guards on the barcode stage: it may not erase, and it may not guess loosely.
|
||||
|
||||
Both of these were latent behind `ENABLE_BARCODE_LOOKUP`, which is false in
|
||||
production. Switching it on to fill barcodes for newly uploaded products would
|
||||
have triggered both at once, so they are fixed before that flag is ever flipped.
|
||||
|
||||
1. THE WIPE
|
||||
`BarcodeResult.as_product_fields()` always returns its full nine keys, and a
|
||||
failed lookup makes every one of them None. `EnrichmentStage.apply` merged
|
||||
that dict straight in, so a MISS replaced the barcode the shop had typed with
|
||||
NULL. Verified end to end before the fix: a sheet sending 8901262010016
|
||||
stored None. Since the cascade misses far more often than it hits, enabling
|
||||
the stage would have destroyed more real barcodes than it found.
|
||||
|
||||
2. THE LOOSE GATE
|
||||
`matching.is_match` defaults to a 0.45 name-similarity floor. That is a fair
|
||||
general default, but by the time a candidate reaches the gate its brand and
|
||||
pack size have already matched, so the name carries the whole decision. At
|
||||
0.45, more than half the accepted matches in this catalogue were a different
|
||||
product - which is how 33 of the 95 stored barcodes came to be wrong.
|
||||
|
||||
Every product name below is real, taken from the live catalogue and from what
|
||||
Open Food Facts actually returns for those codes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.enrichment.barcode import stage as barcode_stage
|
||||
from app.services.enrichment.barcode.models import (
|
||||
BarcodeCandidate,
|
||||
BarcodeResult,
|
||||
LookupStatus,
|
||||
)
|
||||
from app.services.enrichment.barcode.service import BarcodeLookupService
|
||||
|
||||
|
||||
class _Service:
|
||||
"""Stands in for the cascade. Records what it was asked to look up."""
|
||||
|
||||
def __init__(self, result=None):
|
||||
self.result = result or BarcodeResult.null_result(LookupStatus.NOT_FOUND)
|
||||
self.asked = []
|
||||
|
||||
def lookup_one(self, brand, title, size, category=""):
|
||||
self.asked.append(title)
|
||||
return self.result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stage_on(monkeypatch):
|
||||
"""Turn the stage on and hand back the stub service it will use."""
|
||||
service = _Service()
|
||||
monkeypatch.setattr(barcode_stage, "ENABLE_BARCODE_LOOKUP", True)
|
||||
monkeypatch.setattr(barcode_stage, "get_default_service", lambda: service)
|
||||
return service
|
||||
|
||||
|
||||
def _apply(product, brand="Amul"):
|
||||
stage = barcode_stage.BarcodeEnrichmentStage()
|
||||
return asyncio.run(stage.apply(product, brand))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The wipe
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_a_failed_lookup_does_not_erase_the_sheets_barcode(stage_on):
|
||||
"""The regression, stated as plainly as it happened."""
|
||||
product = {"product_name": "Amul Butter 500g", "size": "500g",
|
||||
"barcode": "8901262010016", "barcode_type": "EAN-13"}
|
||||
|
||||
_apply(product)
|
||||
|
||||
assert product["barcode"] == "8901262010016"
|
||||
assert product["barcode_type"] == "EAN-13"
|
||||
|
||||
|
||||
def test_a_product_that_already_has_a_barcode_is_never_looked_up(stage_on):
|
||||
"""Not merely harmless - the lookup is skipped outright.
|
||||
|
||||
A barcode the shop supplied is better evidence than anything the cascade
|
||||
can find: they are holding the pack. Skipping saves the network call too,
|
||||
which on a 2000-row sheet is the difference that matters.
|
||||
"""
|
||||
_apply({"product_name": "Amul Butter 500g", "size": "500g",
|
||||
"barcode": "8901262010016"})
|
||||
|
||||
assert stage_on.asked == [], "a row with a barcode reached the network"
|
||||
|
||||
|
||||
def test_a_blank_barcode_still_gets_looked_up(stage_on):
|
||||
"""The guard must not turn the stage off for the rows it exists to serve."""
|
||||
_apply({"product_name": "Amul Ghee 1L", "size": "1L", "barcode": ""})
|
||||
|
||||
assert stage_on.asked == ["Amul Ghee 1L"]
|
||||
|
||||
|
||||
def test_a_found_barcode_fills_a_blank(stage_on, monkeypatch):
|
||||
found = BarcodeResult(barcode="8901262010023", barcode_type="EAN-13",
|
||||
barcode_verified=True)
|
||||
monkeypatch.setattr(barcode_stage, "get_default_service",
|
||||
lambda: _Service(result=found))
|
||||
product = {"product_name": "Amul Ghee 1L", "size": "1L"}
|
||||
|
||||
_apply(product)
|
||||
|
||||
assert product["barcode"] == "8901262010023"
|
||||
|
||||
|
||||
def test_a_stage_may_still_correct_a_value_just_not_blank_it():
|
||||
"""The merge rule is narrow on purpose.
|
||||
|
||||
Overwriting a value with a DIFFERENT value is what correcting a field
|
||||
means and stays allowed; only blanking a held value is refused. A rule
|
||||
that froze every populated field would break legitimate enrichment.
|
||||
"""
|
||||
from app.services.enrichment.base import EnrichmentStage, StageOutcome
|
||||
|
||||
class _Correcting(EnrichmentStage):
|
||||
name = "test"
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
return True
|
||||
|
||||
async def enrich_one(self, product, brand):
|
||||
return StageOutcome(stage_name=self.name,
|
||||
fields={"category": "Dairy", "barcode": None})
|
||||
|
||||
product = {"category": "General", "barcode": "8901262010016"}
|
||||
asyncio.run(_Correcting().apply(product, "Amul"))
|
||||
|
||||
assert product["category"] == "Dairy", "a real correction was refused"
|
||||
assert product["barcode"] == "8901262010016", "a held value was blanked"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The gate
|
||||
# ---------------------------------------------------------------------------
|
||||
def _match(off_name, off_brand, off_size, our_brand, our_title, our_size):
|
||||
candidate = BarcodeCandidate(barcode="8906021120418", source_name="OFF",
|
||||
candidate_title=off_name,
|
||||
candidate_brand=off_brand,
|
||||
candidate_size=off_size)
|
||||
return BarcodeLookupService._first_validated_match(
|
||||
[candidate], our_brand, our_title, our_size, set())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("off_name,our_title,size", [
|
||||
# Every one of these is a barcode currently stored on the wrong product.
|
||||
("Chicken Kabab/65 Masala", "Aachi Chicken Masala 50g", "50g"),
|
||||
("Chicken Curry Masala", "Aachi Chicken Masala 200g", "200g"),
|
||||
("Tata Tea Gold Care", "Tata Tea Gold 500g", "500g"),
|
||||
("MTR Chana Masala", "MTR Masala 300g", "300g"),
|
||||
("PEPER NOTEN", "Lion Dates Powder 100g", "100g"),
|
||||
])
|
||||
def test_a_different_product_is_no_longer_accepted(off_name, our_title, size):
|
||||
"""Brand and size agree in every case; only the name separates them."""
|
||||
brand = our_title.split()[0]
|
||||
|
||||
assert _match(off_name, brand, size, brand, our_title, size) is None
|
||||
|
||||
|
||||
def test_the_right_product_is_still_accepted():
|
||||
result = _match("Aachi Mutton Masala", "Aachi", "100g",
|
||||
"Aachi", "Aachi Mutton Masala 100g", "100g")
|
||||
|
||||
assert result is not None
|
||||
assert result.barcode
|
||||
|
||||
|
||||
def test_both_directions_share_one_floor():
|
||||
"""A barcode good enough to store must be good enough to read back.
|
||||
|
||||
Two independently-declared copies that drifted apart would give exactly
|
||||
that contradiction, so the forward and reverse paths import the same
|
||||
setting rather than each keeping a constant.
|
||||
"""
|
||||
from app.infrastructure.settings import BARCODE_MIN_NAME_SIMILARITY as configured
|
||||
from app.services.enrichment.barcode.service import BARCODE_MIN_NAME_SIMILARITY as forward
|
||||
from app.services.nutrition_data_service import BARCODE_MIN_NAME_SIMILARITY as reverse
|
||||
|
||||
assert forward == reverse == configured
|
||||
245
tests/test_nutrition_by_barcode.py
Normal file
245
tests/test_nutrition_by_barcode.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Looking a product up by the barcode on its pack, rather than by its name.
|
||||
|
||||
WHAT IS NEW HERE
|
||||
----------------
|
||||
Every other Open Food Facts call in this project searches by brand and product
|
||||
name and scores whatever comes back. That is why OFF-sourced rows in
|
||||
`nutrition_facts` sit at match confidences as low as 0.32 - the module's own
|
||||
`MIN_MATCH_CONFIDENCE`. A barcode is the identifier printed on the pack, so
|
||||
`/api/v2/product/{code}` returns that product or nothing.
|
||||
|
||||
WHY THERE IS STILL A GATE
|
||||
-------------------------
|
||||
The lookup is exact. The STORED BARCODE is not. Measured across all 95 barcoded
|
||||
catalogue rows, a third of the records found this way described a different
|
||||
product, because the barcode on our row was wrong. Every string in the tests
|
||||
below is real - taken from that run, not invented - so if the gate is loosened
|
||||
these fail with the actual products it would let through.
|
||||
|
||||
No network: `requests.get` is stubbed at the boundary, as the existing nutrition
|
||||
tests do.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import nutrition_data_service as nds
|
||||
from app.services.enrichment.barcode.sources import open_food_facts as off
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, payload, status_code=200):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def _product(name, brands, quantity, nutriments=None, code="8906021122290"):
|
||||
return {
|
||||
"code": code,
|
||||
"product_name": name,
|
||||
"brands": brands,
|
||||
"quantity": quantity,
|
||||
"countries_tags": ["en:india"],
|
||||
"nutriments": nutriments if nutriments is not None else {
|
||||
"energy-kcal_100g": 388.2,
|
||||
"proteins_100g": 12.0,
|
||||
"fat_100g": 15.0,
|
||||
"carbohydrates_100g": 45.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def off_returns(monkeypatch):
|
||||
"""Stub the HTTP boundary. Returns a setter taking the JSON body."""
|
||||
box = {}
|
||||
|
||||
def _get(url, **kwargs):
|
||||
if "status" in box and box["status"] is None:
|
||||
raise TimeoutError("simulated network timeout")
|
||||
return _Resp(box.get("body", {"status": 0}))
|
||||
|
||||
monkeypatch.setattr(off.requests, "get", _get)
|
||||
return box
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The lookup itself
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_a_known_barcode_returns_the_product(off_returns):
|
||||
off_returns["body"] = {"status": 1, "product": _product(
|
||||
"Aachi Mutton Masala", "Aachi", "100g")}
|
||||
|
||||
product = off.fetch_product_by_barcode("8906021122290")
|
||||
|
||||
assert product["product_name"] == "Aachi Mutton Masala"
|
||||
|
||||
|
||||
def test_an_unknown_barcode_is_none_not_an_error(off_returns):
|
||||
"""OFF answers HTTP 200 with `status: 0` for a code it has never seen.
|
||||
|
||||
Checking the HTTP code alone would treat that as a hit and hand the caller
|
||||
an empty product. About a third of our barcodes land here, so this is the
|
||||
ordinary path, not an exceptional one.
|
||||
"""
|
||||
off_returns["body"] = {"status": 0, "status_verbose": "product not found"}
|
||||
|
||||
assert off.fetch_product_by_barcode("0000000000000") is None
|
||||
|
||||
|
||||
def test_an_empty_barcode_makes_no_network_call(monkeypatch):
|
||||
called = []
|
||||
monkeypatch.setattr(off.requests, "get",
|
||||
lambda *a, **k: called.append(1) or _Resp({"status": 0}))
|
||||
|
||||
assert off.fetch_product_by_barcode("") is None
|
||||
assert off.fetch_product_by_barcode(" ") is None
|
||||
assert not called, "a blank barcode should never reach the network"
|
||||
|
||||
|
||||
def test_a_network_failure_returns_none_rather_than_raising(off_returns):
|
||||
"""One unreachable host must not take down a whole backfill run."""
|
||||
off_returns["status"] = None # makes the stub raise
|
||||
|
||||
assert off.fetch_product_by_barcode("8906021122290") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The gate - every string below came from the real catalogue
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_the_right_product_is_accepted(off_returns):
|
||||
off_returns["body"] = {"status": 1, "product": _product(
|
||||
"Aachi Mutton Masala", "Aachi", "100g")}
|
||||
|
||||
facts = nds.fetch_verified_nutrition_by_barcode(
|
||||
"8906021122290", "Aachi", "Aachi Mutton Masala 100g", "100g")
|
||||
|
||||
assert facts["data_status"] == "verified"
|
||||
assert facts["data_source"] == nds.BARCODE_DATA_SOURCE
|
||||
assert facts["source_ref"] == "8906021122290"
|
||||
assert facts["calories_kcal"] == 388.2
|
||||
|
||||
|
||||
def test_a_different_product_under_the_same_barcode_is_refused(off_returns):
|
||||
"""The real failure this gate exists for.
|
||||
|
||||
Barcode 8906021120418 is stored on our "Aachi Chicken Masala 50g", but Open
|
||||
Food Facts holds it as "Chicken Kabab/65 Masala" - a different spice blend.
|
||||
Brand and pack size both agree, so the name is the only thing that can tell
|
||||
them apart, and it scores 0.538.
|
||||
"""
|
||||
off_returns["body"] = {"status": 1, "product": _product(
|
||||
"Chicken Kabab/65 Masala", "Aachi", "50g")}
|
||||
|
||||
facts = nds.fetch_verified_nutrition_by_barcode(
|
||||
"8906021120418", "Aachi", "Aachi Chicken Masala 50g", "50g")
|
||||
|
||||
assert facts["data_status"] == "unavailable"
|
||||
assert "calories_kcal" not in facts
|
||||
|
||||
|
||||
def test_a_near_miss_variant_is_refused(off_returns):
|
||||
""""Tata Tea Gold" and "Tata Tea Gold Care" are different products.
|
||||
|
||||
This one scores 0.761 - above matching.py's own 0.45 default, which is why
|
||||
the barcode path sets its own, higher floor rather than reusing it.
|
||||
"""
|
||||
off_returns["body"] = {"status": 1, "product": _product(
|
||||
"Tata Tea Gold Care", "Tata", "500g")}
|
||||
|
||||
facts = nds.fetch_verified_nutrition_by_barcode(
|
||||
"8901030873829", "Tata", "Tata Tea Gold 500g", "500g")
|
||||
|
||||
assert facts["data_status"] == "unavailable"
|
||||
|
||||
|
||||
def test_nonsense_is_refused(off_returns):
|
||||
"""Barcode 20086039 is stored on our Lion Dates Powder; OFF has it as a
|
||||
Dutch confection. Scores 0.097."""
|
||||
off_returns["body"] = {"status": 1, "product": _product(
|
||||
"PEPER NOTEN", "Favorina", "300 g")}
|
||||
|
||||
facts = nds.fetch_verified_nutrition_by_barcode(
|
||||
"20086039", "Lion Dates", "Lion Dates Powder 100g", "50g")
|
||||
|
||||
assert facts["data_status"] == "unavailable"
|
||||
|
||||
|
||||
def test_a_matching_record_with_no_usable_values_is_unavailable(off_returns):
|
||||
"""Our product, but Open Food Facts holds a near-empty record for it.
|
||||
|
||||
Distinct from a mismatch and must not be reported as one: the barcode is
|
||||
right, there is simply nothing to import. Real case - OFF has five
|
||||
nutriment keys for Aachi Chicken Masala 100g and no values among them.
|
||||
"""
|
||||
off_returns["body"] = {"status": 1, "product": _product(
|
||||
"Aachi Mutton Masala", "Aachi", "100g", nutriments={"nova_group": 4})}
|
||||
|
||||
facts = nds.fetch_verified_nutrition_by_barcode(
|
||||
"8906021122290", "Aachi", "Aachi Mutton Masala 100g", "100g")
|
||||
|
||||
assert facts["data_status"] == "unavailable"
|
||||
|
||||
|
||||
def test_the_floor_can_be_relaxed_per_call(off_returns):
|
||||
"""The threshold is a judgement, not a constant, so it is a parameter.
|
||||
|
||||
"Aachi Biryani Masala" against our "Aachi Biryani Masala 50 g" is plainly
|
||||
the same product and scores 0.716 - below the conservative default. The
|
||||
backfill script exposes this as --min-similarity for exactly this reason.
|
||||
"""
|
||||
off_returns["body"] = {"status": 1, "product": _product(
|
||||
"Aachi Biryani Masala", "Aachi", "50 g")}
|
||||
args = ("8906021120272", "Aachi", "Aachi Biryani Masala 50 g", "50 g")
|
||||
|
||||
assert nds.fetch_verified_nutrition_by_barcode(*args)["data_status"] == "unavailable"
|
||||
relaxed = nds.fetch_verified_nutrition_by_barcode(*args, min_name_similarity=0.70)
|
||||
assert relaxed["data_status"] == "verified"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The two paths must stay interchangeable
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_the_barcode_path_returns_the_same_shape_as_the_name_path(off_returns):
|
||||
"""Both feed the same `upsert_nutrition_facts`, so they cannot drift.
|
||||
|
||||
If the barcode path ever grew a key the name path lacks, the writer would
|
||||
silently drop it - the INSERT is built from a fixed column list.
|
||||
"""
|
||||
off_returns["body"] = {"status": 1, "product": _product(
|
||||
"Aachi Mutton Masala", "Aachi", "100g")}
|
||||
|
||||
by_barcode = nds.fetch_verified_nutrition_by_barcode(
|
||||
"8906021122290", "Aachi", "Aachi Mutton Masala 100g", "100g")
|
||||
|
||||
# `name_similarity` is diagnostic and unique to this path; everything else
|
||||
# must exist on the name path too.
|
||||
extra = set(by_barcode) - set(_name_path_keys())
|
||||
assert extra == {"name_similarity"}, f"unexpected new keys: {extra}"
|
||||
|
||||
|
||||
def _name_path_keys():
|
||||
"""The keys `fetch_verified_nutrition` produces on a successful match."""
|
||||
return {
|
||||
"data_status", "data_source", "source_ref", "source_url",
|
||||
"match_confidence", "serving_size_g", "serving_size_label",
|
||||
"extended_nutrients", "per_serving", "ingredients_text",
|
||||
"off_nutriscore", "allergens", "off_labels_tags",
|
||||
"off_ingredients_analysis_tags", "off_categories_tags", "fetched_at",
|
||||
} | set(nds._build_flat_fields({}, "100g"))
|
||||
|
||||
|
||||
def test_a_disabled_open_facts_makes_no_call(monkeypatch):
|
||||
called = []
|
||||
monkeypatch.setattr(off.requests, "get",
|
||||
lambda *a, **k: called.append(1) or _Resp({"status": 0}))
|
||||
monkeypatch.setattr(nds, "USE_OPEN_FACTS", False)
|
||||
|
||||
facts = nds.fetch_verified_nutrition_by_barcode(
|
||||
"8906021122290", "Aachi", "Aachi Mutton Masala 100g", "100g")
|
||||
|
||||
assert facts["data_status"] == "unavailable"
|
||||
assert not called
|
||||
Reference in New Issue
Block a user