diff --git a/backend/app/services/matcher.py b/backend/app/services/matcher.py new file mode 100644 index 0000000..10a3c16 --- /dev/null +++ b/backend/app/services/matcher.py @@ -0,0 +1,135 @@ +"""Fuzzy ingredient<->grocery_item matcher. + +Builds a candidate pool of (text, ingredient_id) tuples from the +canonical ingredient table (name + aliases), ranks each grocery_item +name against the pool with rapidfuzz, and writes the top N matches +above a confidence threshold to the ingredient_grocery_match table. + +Manual matches (source='manual') are preserved across runs. +""" +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import Iterable, List, Tuple +from uuid import UUID + +from rapidfuzz import fuzz, process +from sqlalchemy.orm import Session + +from app.models import ( + GroceryItem, + Ingredient, + IngredientGroceryMatch, + IngredientMatchSource, +) + + +@dataclass +class MatchResult: + ingredient_id: UUID + grocery_item_id: UUID + confidence: float + + +def build_match_pool(ingredients: Iterable[dict]) -> List[Tuple[str, str]]: + """Flatten (canonical name + aliases) into (text, ingredient_id) pairs.""" + pool: List[Tuple[str, str]] = [] + for ing in ingredients: + 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( + db: Session, + *, + source_filter: str = "lucky_california", + top_n: int = 3, + threshold: float = 0.75, +) -> int: + """Refresh ingredient_grocery_match for every grocery_item from `source_filter`. + + Manual matches (source='manual') are NOT touched. Returns the number of + auto rows written/updated. + """ + 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 = ( + db.query(GroceryItem) + .filter(GroceryItem.source == source_filter) + .all() + ) + + written = 0 + for grocery in grocery_rows: + target = " ".join(filter(None, [grocery.name, grocery.brand or ""])).strip() + ranked = rank_candidates(target, pool, top_n=top_n, threshold=threshold) + for r in ranked: + ing_id = UUID(r["ingredient_id"]) + existing = ( + db.query(IngredientGroceryMatch) + .filter( + IngredientGroceryMatch.ingredient_id == ing_id, + IngredientGroceryMatch.grocery_item_id == grocery.id, + ) + .first() + ) + if existing and existing.source == IngredientMatchSource.MANUAL: + continue + confidence = Decimal(str(round(r["confidence"], 3))) + if existing is None: + db.add( + IngredientGroceryMatch( + ingredient_id=ing_id, + grocery_item_id=grocery.id, + confidence=confidence, + source=IngredientMatchSource.AUTO, + ) + ) + else: + existing.confidence = confidence + existing.source = IngredientMatchSource.AUTO + written += 1 + + db.commit() + return written diff --git a/backend/tests/test_matcher.py b/backend/tests/test_matcher.py new file mode 100644 index 0000000..2664633 --- /dev/null +++ b/backend/tests/test_matcher.py @@ -0,0 +1,44 @@ +import pytest + +from app.services.matcher import build_match_pool, rank_candidates + + +def test_build_match_pool_includes_aliases() -> None: + ingredients = [ + {"id": "i1", "name": "Chicken Thighs", "aliases": ["chicken thigh"]}, + {"id": "i2", "name": "Chicken Breast", "aliases": []}, + ] + pool = build_match_pool(ingredients) + # 1 name + 1 alias for i1, 1 name for i2 = 3 entries + assert len(pool) == 3 + assert ("Chicken Thighs", "i1") in pool + assert ("chicken thigh", "i1") in pool + + +def test_rank_candidates_top_n_with_threshold() -> None: + pool = [ + ("Chicken Thighs", "i1"), + ("chicken thigh", "i1"), + ("Chicken Breast", "i2"), + ("Pork Chops", "i3"), + ] + ranked = rank_candidates( + target="Foster Farms Chicken Thighs Family Pack", + pool=pool, + top_n=3, + threshold=0.75, + ) + ids = [item["ingredient_id"] for item in ranked] + assert ids[0] == "i1" + assert all(item["confidence"] >= 0.75 for item in ranked) + + +def test_rank_candidates_drops_below_threshold() -> None: + pool = [("Pork Chops", "i3")] + ranked = rank_candidates( + target="Frosted Flakes Cereal 18oz", + pool=pool, + top_n=3, + threshold=0.75, + ) + assert ranked == []