fix: exact-name fast path in matcher + save priceless produce in scraper

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 20:17:03 -07:00
co-authored by Claude Sonnet 4.6
parent d7a3f5c081
commit 2373883fe7
2 changed files with 25 additions and 6 deletions
-4
View File
@@ -259,10 +259,6 @@ class LuckyCaliforniaScraper:
promo = price_block.get("promoArea") or {} promo = price_block.get("promoArea") or {}
sale_price, sale_unit = cls._parse_price(promo.get("promoText")) 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 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 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. # current_price = "what the customer pays today" → sale_price when on sale.
+25 -2
View File
@@ -56,7 +56,7 @@ _EXCLUSION_WORDS = frozenset({
"margarita", "rita", "cocktail", "beer", "ale", "lager", "cider", "malt", "margarita", "rita", "cocktail", "beer", "ale", "lager", "cider", "malt",
"wine", "spirits", "liquor", "wine", "spirits", "liquor",
"vodka", "tequila", "whiskey", "rum", "gin", "bourbon", "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 / spreads (prevents "Garlic & Herb Butter Spread" matching "Garlic")
"butter", "spread", "margarine", "butter", "spread", "margarine",
# Prepared proteins / seafood-in-oil (prevents "Tuna in Olive Oil" matching "Olive Oil") # 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) ).delete(synchronize_session=False)
db.flush() 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() ingredients = db.query(Ingredient).all()
written = 0 written = 0
@@ -136,6 +141,24 @@ def run_match_job(
a.lower() for a in (ingredient.aliases or []) if a 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_idx: int | None = None
best_combined = 0.0 best_combined = 0.0
@@ -144,7 +167,7 @@ def run_match_job(
query, query,
grocery_names_lower, grocery_names_lower,
scorer=fuzz.partial_token_sort_ratio, scorer=fuzz.partial_token_sort_ratio,
limit=20, limit=100,
) )
for _text, score, idx in results: for _text, score, idx in results:
if score < threshold * 100: if score < threshold * 100: