feat: rapidfuzz-based ingredient<->grocery matcher with manual-pin preservation

This commit is contained in:
2026-05-06 06:18:18 -07:00
parent 489ee03574
commit 6dfb84310f
2 changed files with 179 additions and 0 deletions
+135
View File
@@ -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
+44
View File
@@ -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 == []