diff --git a/backend/app/api/recipes.py b/backend/app/api/recipes.py index 070c876..06e4c56 100644 --- a/backend/app/api/recipes.py +++ b/backend/app/api/recipes.py @@ -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\d+(?:\.\d+)?(?:/\d+)?)\s*(?P[a-zA-Z]+)?\s+(?P.+)$" +) + + +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, + ) diff --git a/backend/tests/test_resolve_ingredient.py b/backend/tests/test_resolve_ingredient.py new file mode 100644 index 0000000..13f4f5c --- /dev/null +++ b/backend/tests/test_resolve_ingredient.py @@ -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