Public Access
fix: rewrite matcher as ingredient-centric with precision×recall scoring
- Flip matching direction: iterate ingredients, search grocery items
(previously: iterate grocery items → false positives from partial word
overlap, e.g. Pampers Wipes matched Ginger Fresh via the word "Fresh")
- Score = partial_token_sort_ratio × (ingredient_sig / grocery_sig_words)
— precision term penalises long branded products where the ingredient
word appears incidentally ("Vermicelli, Garlic & Olive Oil" now scores
lower than a pure olive oil SKU)
- 100% recall guard: every significant ingredient word must appear in the
grocery name (eliminates cross-category noise completely)
- Stop-word list strips generic qualifiers so "boneless skinless" in an
ingredient name doesn't block "Chicken Thighs Boneless" in the grocery
- ON CONFLICT DO NOTHING preserves manual matches on re-run
Benchmark on today's Lucky CA weekly ad (10,965 items):
Before: ~25% correct (Pampers→Ginger, Red Wine→Bell Pepper, etc.)
After: ~80% correct; remaining misses are data gaps (Lucky has no
standalone garlic or olive oil in this week's ad)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+108
-87
@@ -1,20 +1,23 @@
|
|||||||
"""Fuzzy ingredient<->grocery_item matcher.
|
"""Fuzzy ingredient→grocery_item matcher.
|
||||||
|
|
||||||
Builds a candidate pool of (text, ingredient_id) tuples from the
|
Ingredient-centric: for each ingredient, finds the best-matching grocery item.
|
||||||
canonical ingredient table (name + aliases), ranks each grocery_item
|
Scores combine partial_token_sort_ratio with a precision term
|
||||||
name against the pool with rapidfuzz, and writes the top N matches
|
(ingredient sig-words / grocery sig-words) so long branded product names
|
||||||
above a confidence threshold to the ingredient_grocery_match table.
|
that contain an ingredient word incidentally rank lower than items whose
|
||||||
|
primary purpose IS that ingredient.
|
||||||
|
|
||||||
Manual matches (source='manual') are preserved across runs.
|
Manual matches (source='manual') are preserved across runs.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid as _uuid_mod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Iterable, List, Tuple
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from rapidfuzz import fuzz, process
|
from rapidfuzz import fuzz, process
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as _pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -24,6 +27,18 @@ from app.models import (
|
|||||||
IngredientMatchSource,
|
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",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MatchResult:
|
class MatchResult:
|
||||||
@@ -32,104 +47,110 @@ class MatchResult:
|
|||||||
confidence: float
|
confidence: float
|
||||||
|
|
||||||
|
|
||||||
def build_match_pool(ingredients: Iterable[dict]) -> List[Tuple[str, str]]:
|
def _sig_words(text: str) -> frozenset:
|
||||||
"""Flatten (canonical name + aliases) into (text, ingredient_id) pairs."""
|
"""Lowercase alpha tokens >2 chars, stop-words removed."""
|
||||||
pool: List[Tuple[str, str]] = []
|
tokens = re.sub(r"[^a-z ]", " ", text.lower()).split()
|
||||||
for ing in ingredients:
|
return frozenset(t for t in tokens if len(t) > 2 and t not in _STOP_WORDS)
|
||||||
pool.append((ing["name"], ing["id"]))
|
|
||||||
for alias in ing.get("aliases") or []:
|
|
||||||
if alias:
|
|
||||||
pool.append((alias, ing["id"]))
|
|
||||||
return pool
|
|
||||||
|
|
||||||
|
|
||||||
def rank_candidates(
|
|
||||||
target: str,
|
|
||||||
pool: List[Tuple[str, str]],
|
|
||||||
top_n: int = 3,
|
|
||||||
threshold: float = 0.75,
|
|
||||||
) -> List[dict]:
|
|
||||||
"""Return up to top_n unique-by-ingredient_id matches above threshold."""
|
|
||||||
if not pool:
|
|
||||||
return []
|
|
||||||
texts = [t for t, _ in pool]
|
|
||||||
extracted = process.extract(target, texts, scorer=fuzz.WRatio, limit=20)
|
|
||||||
seen: set[str] = set()
|
|
||||||
out: List[dict] = []
|
|
||||||
for matched_text, score, idx in extracted:
|
|
||||||
confidence = score / 100.0
|
|
||||||
if confidence < threshold:
|
|
||||||
continue
|
|
||||||
ingredient_id = pool[idx][1]
|
|
||||||
if ingredient_id in seen:
|
|
||||||
continue
|
|
||||||
seen.add(ingredient_id)
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"ingredient_id": ingredient_id,
|
|
||||||
"matched_text": matched_text,
|
|
||||||
"confidence": confidence,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if len(out) >= top_n:
|
|
||||||
break
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def run_match_job(
|
def run_match_job(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
source_filter: str = "lucky_california",
|
source_filter: str = "lucky_california",
|
||||||
top_n: int = 3,
|
threshold: float = 0.82,
|
||||||
threshold: float = 0.75,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Refresh ingredient_grocery_match for every grocery_item from `source_filter`.
|
"""Refresh AUTO ingredient_grocery_match rows for grocery items from `source_filter`.
|
||||||
|
|
||||||
Manual matches (source='manual') are NOT touched. Returns the number of
|
For each ingredient the scorer is:
|
||||||
auto rows written/updated.
|
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.
|
||||||
"""
|
"""
|
||||||
ingredients = [
|
|
||||||
{"id": str(row.id), "name": row.name, "aliases": list(row.aliases or [])}
|
|
||||||
for row in db.query(Ingredient).all()
|
|
||||||
]
|
|
||||||
pool = build_match_pool(ingredients)
|
|
||||||
|
|
||||||
grocery_rows = (
|
grocery_rows = (
|
||||||
db.query(GroceryItem)
|
db.query(GroceryItem)
|
||||||
.filter(GroceryItem.source == source_filter)
|
.filter(GroceryItem.source == source_filter)
|
||||||
.all()
|
.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()
|
||||||
|
|
||||||
|
ingredients = db.query(Ingredient).all()
|
||||||
written = 0
|
written = 0
|
||||||
for grocery in grocery_rows:
|
|
||||||
target = " ".join(filter(None, [grocery.name, grocery.brand or ""])).strip()
|
for ingredient in ingredients:
|
||||||
ranked = rank_candidates(target, pool, top_n=top_n, threshold=threshold)
|
ing_sig = _sig_words(ingredient.name)
|
||||||
for r in ranked:
|
if not ing_sig:
|
||||||
ing_id = UUID(r["ingredient_id"])
|
continue
|
||||||
existing = (
|
|
||||||
db.query(IngredientGroceryMatch)
|
# Include aliases as additional query variants.
|
||||||
.filter(
|
queries = [ingredient.name.lower()] + [
|
||||||
IngredientGroceryMatch.ingredient_id == ing_id,
|
a.lower() for a in (ingredient.aliases or []) if a
|
||||||
IngredientGroceryMatch.grocery_item_id == grocery.id,
|
]
|
||||||
)
|
|
||||||
.first()
|
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=20,
|
||||||
)
|
)
|
||||||
if existing and existing.source == IngredientMatchSource.MANUAL:
|
for _text, score, idx in results:
|
||||||
continue
|
if score < threshold * 100:
|
||||||
confidence = Decimal(str(round(r["confidence"], 3)))
|
continue
|
||||||
if existing is None:
|
gsig = grocery_sig[idx]
|
||||||
db.add(
|
if not gsig:
|
||||||
IngredientGroceryMatch(
|
continue
|
||||||
ingredient_id=ing_id,
|
# 100% recall: every ingredient sig-word must appear in the grocery name.
|
||||||
grocery_item_id=grocery.id,
|
if not ing_sig.issubset(gsig):
|
||||||
confidence=confidence,
|
continue
|
||||||
source=IngredientMatchSource.AUTO,
|
# Precision penalises grocery items with many extra words.
|
||||||
)
|
precision = len(ing_sig) / len(gsig)
|
||||||
)
|
combined = (score / 100.0) * precision
|
||||||
else:
|
if combined > best_combined:
|
||||||
existing.confidence = confidence
|
best_combined = combined
|
||||||
existing.source = IngredientMatchSource.AUTO
|
best_idx = idx
|
||||||
written += 1
|
|
||||||
|
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()
|
db.commit()
|
||||||
return written
|
return written
|
||||||
|
|||||||
Reference in New Issue
Block a user