Files
adminandClaude Sonnet 4.6 dbc26bcc30 feat: LLM-powered second-pass ingredient matcher + matcher improvements
Matcher improvements (matcher.py):
- Plural normalization: 'tortillas'→'tortilla', 'thighs'→'thigh' so
  subset recall check works without stemmer
- Precision floor lowered 0.45→0.30: allows 'Bacon'→'Wright Brand Bacon'
  (1/3=0.33) while exclusion words still block category contaminants
- _EXCLUSION_WORDS now normalized through same singularizer for consistency

LLM second-pass (llm_matcher.py):
- run_llm_match_job(): for each still-unmatched ingredient, collects top-12
  candidates from grocery catalog ranked by fuzzy×precision (same metric as
  AUTO matcher), then asks Ollama to pick the best match
- Candidate scoring: combined = (partial_token_sort_ratio/100) × precision
  ensures "McCormick Black Pepper" outranks "Dr Pepper" for 'Black Pepper'
- Stores picks as source='auto_llm' (confidence=0.750)
- Ollama Cloud endpoint: https://ollama.com/v1, model: kimi-k2.6:cloud

Migration 0010: adds 'auto_llm' to ingredient_match_source_enum

Config: OLLAMA_BASE_URL / OLLAMA_API_KEY / OLLAMA_MODEL settings
Docker-compose: wires all three Ollama + Spoonacular env vars to backend/scheduler
Scraper service: calls run_llm_match_job after run_match_job on every scrape

Results: AUTO matcher went from 36→25 unmatched (plural normalization fix),
LLM added 3 more (Black Pepper, Zucchini, Chicken Thighs).
Remaining 22 are genuine Lucky CA catalog gaps (standalone olive oil,
dried spices, etc. not in Swiftly weekly ad).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:37:58 -07:00

237 lines
9.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Fuzzy ingredient→grocery_item matcher.
Ingredient-centric: for each ingredient, finds the best-matching grocery item.
Scores combine partial_token_sort_ratio with a precision term
(ingredient sig-words / grocery sig-words) so long branded product names
that contain an ingredient word incidentally rank lower than items whose
primary purpose IS that ingredient.
Manual matches (source='manual') are preserved across runs.
"""
from __future__ import annotations
import re
import uuid as _uuid_mod
from dataclasses import dataclass
from decimal import Decimal
from uuid import UUID
from rapidfuzz import fuzz, process
from sqlalchemy.dialects.postgresql import insert as _pg_insert
from sqlalchemy.orm import Session
from app.models import (
GroceryItem,
Ingredient,
IngredientGroceryMatch,
IngredientMatchSource,
)
# Words that describe quantity/preparation but don't identify the ingredient itself.
# Filtering these from sig-word sets keeps "Chicken Thighs, Boneless Skinless"
# from requiring "boneless" to appear in the grocery name.
_STOP_WORDS = frozenset({
"fresh", "organic", "whole", "large", "small", "medium",
"low", "free", "light", "dark", "raw", "dried", "frozen", "canned",
"extra", "virgin", "pure", "natural", "classic", "style",
"boneless", "skinless", "lean",
"grain", "long", "jarred", "roasted", "smoked", "cooked",
"and", "with", "for", "the",
})
def _singularize(word: str) -> str:
"""Best-effort English singularization for grocery product names.
Handles 'tortillas'→'tortilla', 'thighs'→'thigh', 'chickpeas'→'chickpea'.
Requires length > 3 to avoid mangling short words like 'has', 'was'.
"""
if len(word) > 4 and word.endswith("ies"):
return word[:-3] + "y" # 'berries' → 'berry'
if len(word) > 3 and word.endswith("s") and not word.endswith("ss"):
return word[:-1] # 'tortillas' → 'tortilla'
return word
# 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_RAW = frozenset({
# Baked goods / bread products
"bread", "loaf", "roll", "rolls", "bun", "buns", "croissant",
"cracker", "crackers", "cookie", "cookies", "cake", "cupcake", "muffin", "bagel",
# Chips / snack foods
"chip", "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", "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")
"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", "wipe", "wipes", "diaper",
})
# Pre-normalize exclusion words so they match the singularized sig-word sets.
_EXCLUSION_WORDS = frozenset(_singularize(w) for w in _EXCLUSION_WORDS_RAW)
@dataclass
class MatchResult:
ingredient_id: UUID
grocery_item_id: UUID
confidence: float
def _sig_words(text: str) -> frozenset:
"""Lowercase alpha tokens >2 chars, stop-words removed, plurals normalized."""
tokens = re.sub(r"[^a-z ]", " ", text.lower()).split()
return frozenset(
_singularize(t)
for t in tokens
if len(t) > 2 and t not in _STOP_WORDS
)
def run_match_job(
db: Session,
*,
source_filter: str = "lucky_california",
threshold: float = 0.82,
) -> int:
"""Refresh AUTO ingredient_grocery_match rows for grocery items from `source_filter`.
For each ingredient the scorer is:
combined = partial_token_sort_ratio × (overlap / grocery_sig_count)
where overlap = ingredient sig-words that appear in the grocery sig-words.
This means a product like "Milton's Olive Oil Crackers" (6 sig-words)
scores half of "Bertolli Olive Oil" (3 sig-words) for the ingredient
"Olive Oil", so the simpler/more specific product wins.
100% recall is required: every significant ingredient word must appear
in the grocery name. This eliminates cross-category noise such as
"Ginger, Fresh" → "Pampers Complete Clean Baby Fresh Scent Wipes".
Manual matches (source='manual') are NOT touched.
Returns number of AUTO rows written.
"""
grocery_rows = (
db.query(GroceryItem)
.filter(GroceryItem.source == source_filter)
.all()
)
if not grocery_rows:
return 0
grocery_names_lower = [gi.name.lower() for gi in grocery_rows]
grocery_sig = [_sig_words(gi.name) for gi in grocery_rows]
grocery_ids = [gi.id for gi in grocery_rows]
# Purge existing AUTO matches for this source's items before re-matching.
db.query(IngredientGroceryMatch).filter(
IngredientGroceryMatch.grocery_item_id.in_([gi.id for gi in grocery_rows]),
IngredientGroceryMatch.source == IngredientMatchSource.AUTO,
).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
for ingredient in ingredients:
ing_sig = _sig_words(ingredient.name)
if not ing_sig:
continue
# Include aliases as additional query variants.
queries = [ingredient.name.lower()] + [
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
for query in queries:
results = process.extract(
query,
grocery_names_lower,
scorer=fuzz.partial_token_sort_ratio,
limit=100,
)
for _text, score, idx in results:
if score < threshold * 100:
continue
gsig = grocery_sig[idx]
if not gsig:
continue
# 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 dwarf the ingredient in sig-word count.
# 0.30 allows "Bacon" (1 sig-word) → "Wright Brand Bacon" (3 sig-words)
# while exclusion words still block "Garlic Herb Rotisserie Chicken".
if precision < 0.30:
continue
combined = (score / 100.0) * precision
if combined > best_combined:
best_combined = combined
best_idx = idx
if best_idx is None:
continue
# ON CONFLICT DO NOTHING preserves any existing MANUAL match for the same pair.
stmt = (
_pg_insert(IngredientGroceryMatch.__table__)
.values(
id=_uuid_mod.uuid4(),
ingredient_id=ingredient.id,
grocery_item_id=grocery_ids[best_idx],
confidence=Decimal(str(round(best_combined, 3))),
source=IngredientMatchSource.AUTO,
)
.on_conflict_do_nothing(index_elements=["ingredient_id", "grocery_item_id"])
)
db.execute(stmt)
written += 1
db.commit()
return written