Brand valid image generation
This commit is contained in:
@@ -98,7 +98,7 @@ from app.services.vector_store import _connect
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger("backfill_barcodes_off")
|
||||
|
||||
BARCODE_SOURCE = "openfoodfacts_bulk (search.openfoodfacts.org)"
|
||||
BARCODE_SOURCE = "openfoodfacts_bulk (world.openfoodfacts.org/api/v2)"
|
||||
|
||||
# The nine keys BarcodeResult.as_product_fields() emits. Named here so the JSON
|
||||
# writer can prove it touches nothing else.
|
||||
|
||||
@@ -30,7 +30,10 @@ USAGE
|
||||
python scripts/backfill_brand_stores.py --all # dry run
|
||||
python scripts/backfill_brand_stores.py --all --apply
|
||||
python scripts/backfill_brand_stores.py --brand Gopuram --apply --refresh
|
||||
python scripts/backfill_brand_stores.py --brand Naga --domain nagafoods.in --apply
|
||||
python scripts/backfill_brand_stores.py --brand Aachi --domain aachifoods.com --apply
|
||||
|
||||
(Naga has no confirmed storefront to try: nagalimited.com is a parked domain
|
||||
and nagafoods.in / nagafoods.com do not resolve, checked 2026-09-11.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
212
scripts/regate_brand_images.py
Normal file
212
scripts/regate_brand_images.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Re-run the image gate over a brand's STORED images, and drop what fails it.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
`scripts/repair_brand_images.py` fixes rows whose image URLs are DEAD. This
|
||||
one fixes rows whose image URLs are ALIVE AND WRONG, which the repair script
|
||||
refuses to touch by design ("never rewrites a row that already has one working
|
||||
URL").
|
||||
|
||||
The Dabur run of 2026-09-11 left exactly that: "Dabur Honey 1kg / 250g / 50g"
|
||||
with an empty `image_url` and four foreign honeys in `image_urls` - which the
|
||||
product card shows as soon as the primary is blank - and "Dabur Honey 500g"
|
||||
with a Dabur Odomos mosquito repellent as its primary. The ingestion path no
|
||||
longer stores rejected candidates (`store_catalog_pipeline.stage_6_images`),
|
||||
but rows written before that keep them, and a re-ingest does not revisit a
|
||||
row that already has images. Hence this script.
|
||||
|
||||
WHAT IT DOES, PER ROW
|
||||
---------------------
|
||||
1. Runs `image_corroboration.choose_primary` over every URL the row holds
|
||||
(primary first). `image_url` becomes the eligible primary, `image_urls`
|
||||
the eligible list; rejected URLs are dropped.
|
||||
2. If nothing is eligible, looks for another pack size of the same product
|
||||
(same `search_key`) whose primary passed, and adopts it - through the gate
|
||||
for THIS title, exactly as stage 6 does.
|
||||
3. If still nothing, `image_url` becomes NULL and `image_urls` empty. The card
|
||||
renders the brand monogram, which is honest; a foreign honey is not.
|
||||
|
||||
Own Products is skipped: its images are curated (`produce_reference`) and the
|
||||
gate was never meant for a bucket without a brand.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
python -m scripts.regate_brand_images --brands Dabur # dry run
|
||||
python -m scripts.regate_brand_images --brands Dabur --apply
|
||||
python -m scripts.regate_brand_images --all # dry run, every brand
|
||||
|
||||
`--apply` writes a full backup of each targeted table first, in the same
|
||||
format `repair_brand_images.py --restore` reads, so the change is reversible:
|
||||
|
||||
python -m scripts.repair_brand_images --restore data/brand_image_repair_backup_X.json --apply
|
||||
|
||||
The Open*Facts barcode cross-check is a network call per distinct barcode
|
||||
(cached 30 days in data/cache/image_corroboration.db). Everything else is
|
||||
offline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
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 # noqa: E402
|
||||
from app.services import image_corroboration as ic # noqa: E402
|
||||
from app.services.generic_products import OWN_PRODUCTS_BRAND # noqa: E402
|
||||
from app.services.vector_store import ( # noqa: E402
|
||||
_connect,
|
||||
_sanitize_name,
|
||||
invalidate_brand_overview_cache,
|
||||
)
|
||||
from scripts.repair_brand_images import ( # noqa: E402
|
||||
_backup,
|
||||
_brand_tables,
|
||||
_read_rows,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger("regate_brand_images")
|
||||
|
||||
|
||||
def _row_urls(row: Dict[str, Any]) -> List[str]:
|
||||
"""Primary first, then the rest, deduplicated, http only."""
|
||||
out: List[str] = []
|
||||
for url in [row.get("image_url")] + list(row.get("image_urls") or []):
|
||||
if url and str(url).startswith("http") and url not in out:
|
||||
out.append(str(url))
|
||||
return out
|
||||
|
||||
|
||||
def regate_rows(rows: List[Dict[str, Any]], brand: str) -> List[Dict[str, Any]]:
|
||||
"""The new (image_url, image_urls) for every row, plus why.
|
||||
|
||||
Pure apart from the Open*Facts barcode cross-check inside choose_primary,
|
||||
so it can be unit-tested with that patched.
|
||||
"""
|
||||
verdicts: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
urls = _row_urls(row)
|
||||
name = row.get("product_name") or ""
|
||||
if not urls:
|
||||
verdicts.append({**row, "new_url": None, "new_urls": [], "why": "no images stored"})
|
||||
continue
|
||||
choice = ic.choose_primary(urls, name, brand)
|
||||
verdicts.append({
|
||||
**row,
|
||||
"new_url": choice.primary,
|
||||
"new_urls": list(choice.eligible),
|
||||
"rejected": list(choice.rejected),
|
||||
"why": choice.reason,
|
||||
})
|
||||
|
||||
# Sibling adoption for rows left with nothing, from rows that kept a primary.
|
||||
eligible_by_key: Dict[tuple, str] = {}
|
||||
for v in verdicts:
|
||||
if v["new_url"]:
|
||||
eligible_by_key.setdefault(ic.search_key(v.get("product_name") or "", brand), v["new_url"])
|
||||
for v in verdicts:
|
||||
if v["new_url"]:
|
||||
continue
|
||||
candidate = eligible_by_key.get(ic.search_key(v.get("product_name") or "", brand))
|
||||
if not candidate:
|
||||
continue
|
||||
adopted = ic.choose_primary([candidate], v.get("product_name") or "", brand)
|
||||
if adopted.primary:
|
||||
v["new_url"] = adopted.primary
|
||||
v["new_urls"] = [adopted.primary]
|
||||
v["why"] = "adopted from another pack size of this product"
|
||||
return verdicts
|
||||
|
||||
|
||||
def _changed(v: Dict[str, Any]) -> bool:
|
||||
return (v.get("image_url") or None) != v["new_url"] or list(v.get("image_urls") or []) != v["new_urls"]
|
||||
|
||||
|
||||
def regate(cur, conn, brands: Optional[List[str]], apply: bool) -> Dict[str, int]:
|
||||
totals = {"rows": 0, "changed": 0, "blanked": 0, "adopted": 0}
|
||||
snapshot: Dict[str, List[Dict[str, Any]]] = {}
|
||||
plans: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
for suffix, table in _brand_tables(cur, brands):
|
||||
if suffix == _sanitize_name(OWN_PRODUCTS_BRAND):
|
||||
logger.info("%s: skipped (curated images)", table)
|
||||
continue
|
||||
rows = _read_rows(cur, table)
|
||||
if not rows:
|
||||
continue
|
||||
snapshot[table] = rows
|
||||
verdicts = [v for v in regate_rows(rows, suffix) if _changed(v)]
|
||||
plans[table] = verdicts
|
||||
totals["rows"] += len(rows)
|
||||
logger.info("%s: %d row(s), %d to change", table, len(rows), len(verdicts))
|
||||
for v in verdicts:
|
||||
arrow = "->"
|
||||
logger.info(" %-42s %s %s", (v.get("product_name") or "")[:42], arrow,
|
||||
(v["new_url"] or "(none)")[:80])
|
||||
logger.info(" was %s (+%d more) %s", (v.get("image_url") or "(none)")[:70],
|
||||
max(0, len(v.get("image_urls") or []) - 1), v["why"])
|
||||
for dropped in v.get("rejected") or []:
|
||||
logger.info(" drop %s", dropped[:90])
|
||||
totals["changed"] += 1
|
||||
if v["new_url"] is None:
|
||||
totals["blanked"] += 1
|
||||
if v["why"].startswith("adopted"):
|
||||
totals["adopted"] += 1
|
||||
|
||||
if apply and any(plans.values()):
|
||||
logger.info("Backup written: %s", _backup(snapshot))
|
||||
for table, verdicts in plans.items():
|
||||
for v in verdicts:
|
||||
cur.execute(
|
||||
f"UPDATE {table} SET image_url = %s, image_urls = %s, "
|
||||
f"updated_at = CURRENT_TIMESTAMP WHERE image_id = %s",
|
||||
(v["new_url"], v["new_urls"], v["image_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
invalidate_brand_overview_cache()
|
||||
except Exception: # noqa: BLE001 - the cache is a convenience
|
||||
pass
|
||||
return totals
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--brands", help="comma-separated brands to re-gate")
|
||||
mode.add_argument("--all", action="store_true", help="every brand table")
|
||||
parser.add_argument("--apply", action="store_true", help="write the changes (default: dry run)")
|
||||
args = parser.parse_args()
|
||||
|
||||
logger.info("Target database: %s / %s", DB_HOST, DB_NAME)
|
||||
logger.info("Mode: %s", "APPLY - this writes" if args.apply else "DRY RUN - nothing is written")
|
||||
|
||||
conn = _connect()
|
||||
if conn is None:
|
||||
logger.error("No database connection.")
|
||||
return 1
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
brands = None if args.all else [b for b in args.brands.split(",") if b.strip()]
|
||||
totals = regate(cur, conn, brands, args.apply)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("")
|
||||
logger.info("%s: %d row(s) read, %d %s, %d blanked, %d adopted from a sibling size",
|
||||
"Applied" if args.apply else "Dry run", totals["rows"], totals["changed"],
|
||||
"changed" if args.apply else "would change", totals["blanked"], totals["adopted"])
|
||||
if args.apply:
|
||||
logger.info("The API caches the brand overview; hit GET /api/brands/overview?refresh=true "
|
||||
"or wait out BRAND_OVERVIEW_TTL_SECONDS.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user