From d7a3f5c0813e7d8c411bbc2681488a2a01b1c6ec Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Sun, 10 May 2026 12:00:19 -0700 Subject: [PATCH] fix: matcher exclusion words + precision floor for clean grocery matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add exclusion words: soda, rotisserie, tuna/tonno/salmon/sardine/anchovy to prevent beverages, prepared poultry, and seafood-in-oil from matching raw cooking ingredients - Add min precision floor (0.45): grocery sig-word count must be ≤ 2× the ingredient's sig-word count, catching long branded products that pass the word-overlap recall check but are clearly wrong category matches (e.g. "Garlic Herb Rotisserie Chicken" precision=0.25 now rejected) Result: all previously wrong matches now show '—' (no match) rather than a wrong product; correct matches unchanged Co-Authored-By: Claude Sonnet 4.6 (1M context) --- backend/app/services/matcher.py | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/backend/app/services/matcher.py b/backend/app/services/matcher.py index 78f17cb..7b37222 100644 --- a/backend/app/services/matcher.py +++ b/backend/app/services/matcher.py @@ -39,6 +39,34 @@ _STOP_WORDS = frozenset({ "and", "with", "for", "the", }) +# If any of these words appear in a grocery item's sig-words but NOT in the +# ingredient's sig-words, the match is rejected outright. Prevents category +# cross-contamination: "Garlic" must not match "Garlic Bread", "Lime" must not +# match "Lime Margarita", etc. +_EXCLUSION_WORDS = frozenset({ + # Baked goods / bread products + "bread", "loaf", "rolls", "bun", "buns", "croissant", + "cracker", "crackers", "cookie", "cookies", "cake", "cupcake", "muffin", "bagel", + # Chips / snack foods + "chips", + # Pasta / noodles + "pasta", "noodle", "noodles", "vermicelli", "spaghetti", "linguine", + "fettuccine", "penne", "rigatoni", "macaroni", "rotini", "orzo", + # Alcoholic / mixed beverages + "margarita", "rita", "cocktail", "beer", "ale", "lager", "cider", "malt", + "wine", "spirits", "liquor", + "vodka", "tequila", "whiskey", "rum", "gin", "bourbon", + "lemonade", "limeade", "seltzer", "soda", + # 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") + "tuna", "tonno", "salmon", "sardine", "anchovy", + # Prepared poultry (prevents "Garlic Herb Rotisserie Chicken" matching "Garlic") + "rotisserie", + # Baby / personal care (belt-and-suspenders after stop-word rework) + "baby", "wipes", "diaper", +}) + @dataclass class MatchResult: @@ -127,8 +155,18 @@ def run_match_job( # 100% recall: every ingredient sig-word must appear in the grocery name. if not ing_sig.issubset(gsig): continue + # Category exclusion: reject if grocery has a disqualifying word + # (e.g. "bread", "chips", "margarita") absent from the ingredient. + bad_words = (gsig & _EXCLUSION_WORDS) - ing_sig + if bad_words: + continue # Precision penalises grocery items with many extra words. precision = len(ing_sig) / len(gsig) + # Hard floor: grocery must not have >2× the sig-words of the ingredient. + # Catches long branded products that sneak past exclusion words, e.g. + # "Garlic Herb Rotisserie Chicken" for "Garlic". + if precision < 0.45: + continue combined = (score / 100.0) * precision if combined > best_combined: best_combined = combined