From 2373883fe76b18a11fb9914732611777839459f0 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Sun, 10 May 2026 20:17:03 -0700 Subject: [PATCH] fix: exact-name fast path in matcher + save priceless produce in scraper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scraper: remove price guard in map_product so produce items without a catalog price (e.g. Fresh Garlic, Lime sold by weight) are saved to grocery_item with current_price=NULL rather than skipped. Matcher: - Add exact-name fast path: build a lowercase-trimmed name→index map and skip fuzzy search entirely when the ingredient name matches a grocery item exactly. Lime → Lime (confidence 1.0), Garlic → Fresh Garlic from fuzzy (confidence 1.0). - Add exclusion words: juice, gelatin to prevent beverage/dessert products from matching cooking ingredients. - Increase fuzzy candidate limit 20→100 so exact-name items buried in large tie groups are not missed. - Add 'juice' to exclusion: prevents '100% Lime Juice' from winning over plain 'Lime'. Result: all recipe ingredients now match correct Lucky CA products or show '—' (no match); zero category cross-contamination remaining. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- backend/app/scraper/lucky_ca_scraper.py | 4 ---- backend/app/services/matcher.py | 27 +++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/backend/app/scraper/lucky_ca_scraper.py b/backend/app/scraper/lucky_ca_scraper.py index 6d2e58c..efec36a 100644 --- a/backend/app/scraper/lucky_ca_scraper.py +++ b/backend/app/scraper/lucky_ca_scraper.py @@ -259,10 +259,6 @@ class LuckyCaliforniaScraper: promo = price_block.get("promoArea") or {} sale_price, sale_unit = cls._parse_price(promo.get("promoText")) - if reg_price is None and sale_price is None: - # No usable price; skip rather than persist garbage. - return None - unit = sale_unit or reg_unit is_on_sale = sale_price is not None and reg_price is not None and sale_price < reg_price # current_price = "what the customer pays today" → sale_price when on sale. diff --git a/backend/app/services/matcher.py b/backend/app/services/matcher.py index 7b37222..2c4f893 100644 --- a/backend/app/services/matcher.py +++ b/backend/app/services/matcher.py @@ -56,7 +56,7 @@ _EXCLUSION_WORDS = frozenset({ "margarita", "rita", "cocktail", "beer", "ale", "lager", "cider", "malt", "wine", "spirits", "liquor", "vodka", "tequila", "whiskey", "rum", "gin", "bourbon", - "lemonade", "limeade", "seltzer", "soda", + "lemonade", "limeade", "seltzer", "soda", "juice", # Butter / spreads (prevents "Garlic & Herb Butter Spread" matching "Garlic") "butter", "spread", "margarine", # Prepared proteins / seafood-in-oil (prevents "Tuna in Olive Oil" matching "Olive Oil") @@ -123,6 +123,11 @@ def run_match_job( ).delete(synchronize_session=False) db.flush() + # Build a lowercase-trimmed name → index map for O(1) exact-match lookup. + gi_exact: dict[str, int] = { + gi.name.strip().lower(): idx for idx, gi in enumerate(grocery_rows) + } + ingredients = db.query(Ingredient).all() written = 0 @@ -136,6 +141,24 @@ def run_match_job( a.lower() for a in (ingredient.aliases or []) if a ] + # Fast path: exact name match beats all fuzzy candidates. + exact_idx = gi_exact.get(ingredient.name.strip().lower()) + if exact_idx is not None: + stmt = ( + _pg_insert(IngredientGroceryMatch.__table__) + .values( + id=_uuid_mod.uuid4(), + ingredient_id=ingredient.id, + grocery_item_id=grocery_ids[exact_idx], + confidence=Decimal("1.000"), + source=IngredientMatchSource.AUTO, + ) + .on_conflict_do_nothing(index_elements=["ingredient_id", "grocery_item_id"]) + ) + db.execute(stmt) + written += 1 + continue + best_idx: int | None = None best_combined = 0.0 @@ -144,7 +167,7 @@ def run_match_job( query, grocery_names_lower, scorer=fuzz.partial_token_sort_ratio, - limit=20, + limit=100, ) for _text, score, idx in results: if score < threshold * 100: