Brand valid image generation

This commit is contained in:
sriram
2026-09-11 15:47:56 +05:30
parent e1a5962f82
commit ce4fa70dee
31 changed files with 5586 additions and 1495 deletions

View File

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

View File

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

View 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())