Public Access
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>
237 lines
9.0 KiB
Python
237 lines
9.0 KiB
Python
"""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
|