Public Access
feat: POST /api/admin/recipes/resolve-ingredient with rapidfuzz top-3
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user