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