Backend catalog recent updates
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user