Public Access
feat: rapidfuzz-based ingredient<->grocery matcher with manual-pin preservation
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user