feat: POST /api/admin/recipes/resolve-ingredient with rapidfuzz top-3

This commit is contained in:
2026-05-06 06:16:29 -07:00
parent f16a2f8710
commit 489ee03574
2 changed files with 139 additions and 1 deletions
+92 -1
View File
@@ -1,14 +1,23 @@
from __future__ import annotations
import re
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from rapidfuzz import fuzz, process
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Ingredient, Recipe
from app.schemas.recipe import RecipeCreate, RecipeRead, RecipeUpdate
from app.schemas.recipe import (
RecipeCreate,
RecipeRead,
RecipeUpdate,
ResolveIngredientCandidate,
ResolveIngredientRequest,
ResolveIngredientResponse,
)
from app.security import require_admin
@@ -127,3 +136,85 @@ def delete_recipe(recipe_id: UUID, db: Session = Depends(get_db)):
db.delete(row)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
_KNOWN_UNITS = {
"tsp", "tbsp", "cup", "cups", "oz", "ounce", "ounces",
"lb", "lbs", "pound", "pounds", "g", "kg", "ml", "l",
"clove", "cloves", "pinch", "dash", "ea", "each",
}
_QTY_UNIT_RE = re.compile(
r"^\s*(?P<qty>\d+(?:\.\d+)?(?:/\d+)?)\s*(?P<unit>[a-zA-Z]+)?\s+(?P<rest>.+)$"
)
def _parse_qty_unit(text: str) -> tuple[Optional[float], Optional[str], str]:
m = _QTY_UNIT_RE.match(text)
if not m:
return None, None, text.strip()
qty_raw = m.group("qty")
if "/" in qty_raw:
num, denom = qty_raw.split("/")
qty = float(num) / float(denom)
else:
qty = float(qty_raw)
unit = m.group("unit")
rest = m.group("rest").strip()
if unit and unit.lower() not in _KNOWN_UNITS:
rest = f"{unit} {rest}"
unit = None
return qty, unit.lower() if unit else None, rest
@admin_router.post("/resolve-ingredient", response_model=ResolveIngredientResponse)
def resolve_ingredient(
payload: ResolveIngredientRequest,
db: Session = Depends(get_db),
) -> ResolveIngredientResponse:
qty, unit, rest = _parse_qty_unit(payload.text)
rows = db.query(Ingredient).all()
if not rows:
return ResolveIngredientResponse(
parsed_qty=qty,
parsed_unit=unit,
parsed_text=rest,
candidates=[],
)
pool: list[tuple[str, UUID, Optional[str]]] = []
for row in rows:
pool.append((row.name, row.id, row.aisle))
for alias in row.aliases or []:
pool.append((alias, row.id, row.aisle))
scored = process.extract(
rest,
[name for name, _, _ in pool],
scorer=fuzz.WRatio,
limit=10,
)
seen: set[UUID] = set()
candidates: list[ResolveIngredientCandidate] = []
for matched_name, score, idx in scored:
_, ing_id, aisle = pool[idx]
if ing_id in seen:
continue
seen.add(ing_id)
candidates.append(
ResolveIngredientCandidate(
ingredient_id=ing_id,
name=matched_name,
score=score / 100.0,
aisle=aisle,
)
)
if len(candidates) >= 3:
break
return ResolveIngredientResponse(
parsed_qty=qty,
parsed_unit=unit,
parsed_text=rest,
candidates=candidates,
)