Backend catalog recent updates

This commit is contained in:
sriram
2026-09-01 18:02:11 +05:30
parent 68bff007ea
commit 7b3fbc47b4
11 changed files with 1457 additions and 179 deletions

View File

@@ -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)

View File

@@ -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"

View File

@@ -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 ""

View File

@@ -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