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 from __future__ import annotations
import re
from typing import List, Optional from typing import List, Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from rapidfuzz import fuzz, process
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import Ingredient, Recipe 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 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.delete(row)
db.commit() db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT) 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,
)
+47
View File
@@ -0,0 +1,47 @@
import pytest
pytestmark = pytest.mark.requires_postgres
def _admin() -> dict:
return {"Authorization": "Bearer test-admin-token"}
def _seed_ingredient(client, name: str, aliases: list[str]) -> str:
r = client.post(
"/api/admin/ingredients",
json={"name": name, "aliases": aliases, "aisle": "pantry", "unit": "ea"},
headers=_admin(),
)
return r.json()["id"]
def test_resolve_ingredient_returns_top_three_candidates(client):
chicken_id = _seed_ingredient(client, "Test Resolve Chicken Thighs", ["chicken thigh resolve"])
breast_id = _seed_ingredient(client, "Test Resolve Chicken Breast", ["chicken breasts resolve"])
pork_id = _seed_ingredient(client, "Test Resolve Pork Chop", ["pork chops resolve"])
r = client.post(
"/api/admin/recipes/resolve-ingredient",
json={"text": "1 lb chicken thigh resolve"},
headers=_admin(),
)
assert r.status_code == 200, r.text
data = r.json()
assert data["parsed_qty"] == 1.0
assert data["parsed_unit"] == "lb"
candidate_ids = [c["ingredient_id"] for c in data["candidates"]]
assert chicken_id in candidate_ids
assert candidate_ids[0] == chicken_id # highest score should be exact match
def test_resolve_ingredient_handles_no_unit(client):
_seed_ingredient(client, "Test Resolve Lemon Special", ["lemons resolve"])
r = client.post(
"/api/admin/recipes/resolve-ingredient",
json={"text": "2 lemons resolve"},
headers=_admin(),
)
data = r.json()
assert data["parsed_qty"] == 2.0
assert data["parsed_unit"] is None