diff --git a/alembic/versions/2023-09-01-14.55.42_0341b154f79a_.py b/alembic/versions/2023-09-01-14.55.42_0341b154f79a_.py new file mode 100644 index 000000000000..fe46a4085933 --- /dev/null +++ b/alembic/versions/2023-09-01-14.55.42_0341b154f79a_.py @@ -0,0 +1,71 @@ +"""empty message + +Revision ID: 0341b154f79a +Revises: bcfdad6b7355 +Create Date: 2023-09-01 14:55:42.166766 + +""" +import sqlalchemy as sa +from sqlalchemy import orm, select + +from alembic import op +from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel + +# revision identifiers, used by Alembic. +revision = "0341b154f79a" +down_revision = "bcfdad6b7355" +branch_labels = None +depends_on = None + + +def populate_normalized_fields(): + bind = op.get_bind() + session = orm.Session(bind=bind) + + units = session.execute(select(IngredientUnitModel)).scalars().all() + for unit in units: + if unit.name is not None: + unit.name_normalized = IngredientUnitModel.normalize(unit.name) + + if unit.abbreviation is not None: + unit.abbreviation_normalized = IngredientUnitModel.normalize(unit.abbreviation) + + session.add(unit) + + foods = session.execute(select(IngredientFoodModel)).scalars().all() + for food in foods: + if food.name is not None: + food.name_normalized = IngredientFoodModel.normalize(food.name) + + session.add(food) + + session.commit() + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("ingredient_foods", sa.Column("name_normalized", sa.String(), nullable=True)) + op.create_index(op.f("ix_ingredient_foods_name_normalized"), "ingredient_foods", ["name_normalized"], unique=False) + op.add_column("ingredient_units", sa.Column("name_normalized", sa.String(), nullable=True)) + op.add_column("ingredient_units", sa.Column("abbreviation_normalized", sa.String(), nullable=True)) + op.create_index( + op.f("ix_ingredient_units_abbreviation_normalized"), + "ingredient_units", + ["abbreviation_normalized"], + unique=False, + ) + op.create_index(op.f("ix_ingredient_units_name_normalized"), "ingredient_units", ["name_normalized"], unique=False) + # ### end Alembic commands ### + + populate_normalized_fields() + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_ingredient_units_name_normalized"), table_name="ingredient_units") + op.drop_index(op.f("ix_ingredient_units_abbreviation_normalized"), table_name="ingredient_units") + op.drop_column("ingredient_units", "abbreviation_normalized") + op.drop_column("ingredient_units", "name_normalized") + op.drop_index(op.f("ix_ingredient_foods_name_normalized"), table_name="ingredient_foods") + op.drop_column("ingredient_foods", "name_normalized") + # ### end Alembic commands ### diff --git a/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue b/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue index 345615d9368b..94d41593ae00 100644 --- a/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue +++ b/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue @@ -205,8 +205,7 @@ export default defineComponent({ async function createAssignFood() { foodData.data.name = foodSearch.value; - await foodStore.actions.createOne(foodData.data); - props.value.food = foodStore.foods.value?.find((food) => food.name === foodSearch.value); + props.value.food = await foodStore.actions.createOne(foodData.data) || undefined; foodData.reset(); } @@ -218,8 +217,7 @@ export default defineComponent({ async function createAssignUnit() { unitsData.data.name = unitSearch.value; - await unitStore.actions.createOne(unitsData.data); - props.value.unit = unitStore.units.value?.find((unit) => unit.name === unitSearch.value); + props.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined; unitsData.reset(); } diff --git a/frontend/composables/partials/use-actions-factory.ts b/frontend/composables/partials/use-actions-factory.ts index c743cb36eec3..be9dd21a2169 100644 --- a/frontend/composables/partials/use-actions-factory.ts +++ b/frontend/composables/partials/use-actions-factory.ts @@ -13,9 +13,9 @@ interface PublicStoreActions { } interface StoreActions extends PublicStoreActions { - createOne(createData: T): Promise; - updateOne(updateData: T): Promise; - deleteOne(id: string | number): Promise; + createOne(createData: T): Promise; + updateOne(updateData: T): Promise; + deleteOne(id: string | number): Promise; } @@ -121,31 +121,34 @@ export function useStoreActions( if (data && allRef?.value) { allRef.value.push(data); } else { - refresh(); + await refresh(); } loading.value = false; + return data; } async function updateOne(updateData: T) { if (!updateData.id) { - return; + return null; } loading.value = true; const { data } = await api.updateOne(updateData.id, updateData); if (data && allRef?.value) { - refresh(); + await refresh(); } loading.value = false; + return data; } async function deleteOne(id: string | number) { loading.value = true; const { response } = await api.deleteOne(id); if (response && allRef?.value) { - refresh(); + await refresh(); } loading.value = false; + return response?.data || null; } return { diff --git a/frontend/pages/recipe/_slug/ingredient-parser.vue b/frontend/pages/recipe/_slug/ingredient-parser.vue index 9c2dcbfe2070..2bc91da8e2e5 100644 --- a/frontend/pages/recipe/_slug/ingredient-parser.vue +++ b/frontend/pages/recipe/_slug/ingredient-parser.vue @@ -68,7 +68,15 @@ {{ ing.input }} - + + + {{ errors[index].unitErrorMessage }} + ([]); function checkForUnit(unit?: IngredientUnit | CreateIngredientUnit) { - if (!unit) { - return false; - } - if (units.value && unit?.name) { - const lower = unit.name.toLowerCase(); - return units.value.some((u) => u.name.toLowerCase() === lower); - } - return false; + // @ts-expect-error; we're just checking if there's an id on this unit and returning a boolean + return !!unit?.id; } function checkForFood(food?: IngredientFood | CreateIngredientFood) { - if (!food) { - return false; - } - if (foodStore.foods.value && food?.name) { - const lower = food.name.toLowerCase(); - return foodStore.foods.value.some((f) => f.name.toLowerCase() === lower); - } - return false; + // @ts-expect-error; we're just checking if there's an id on this food and returning a boolean + return !!food?.id; } async function createFood(food: CreateIngredientFood | undefined, index: number) { @@ -247,11 +244,24 @@ export default defineComponent({ } foodData.data.name = food.name; - await foodStore.actions.createOne(foodData.data); + parsedIng.value[index].ingredient.food = await foodStore.actions.createOne(foodData.data) || undefined; errors.value[index].foodError = false; + foodData.reset(); } + async function createUnit(unit: CreateIngredientUnit | undefined, index: number) { + if (!unit) { + return; + } + + unitData.data.name = unit.name; + parsedIng.value[index].ingredient.unit = await unitStore.actions.createOne(unitData.data) || undefined; + errors.value[index].unitError = false; + + unitData.reset(); + } + function insertIngredient(index: number) { if (!recipe.value?.recipeIngredient) { return; @@ -287,27 +297,21 @@ export default defineComponent({ // ========================================================= // Save All Logic async function saveAll() { - let ingredients = parsedIng.value.map((ing) => { + const ingredients = parsedIng.value.map((ing) => { + if (!checkForFood(ing.ingredient.food)) { + ing.ingredient.food = undefined; + } + + if (!checkForUnit(ing.ingredient.unit)) { + ing.ingredient.unit = undefined; + } + return { ...ing.ingredient, originalText: ing.input, } as RecipeIngredient; }); - ingredients = ingredients.map((ing) => { - if (!foodStore.foods.value || !units.value) { - return ing; - } - // Get food from foods - const lowerFood = ing.food?.name?.toLowerCase(); - ing.food = foodStore.foods.value.find((f) => f.name.toLowerCase() === lowerFood); - - // Get unit from units - const lowerUnit = ing.unit?.name?.toLowerCase(); - ing.unit = units.value.find((u) => u.name.toLowerCase() === lowerUnit); - return ing; - }); - if (!recipe.value || !recipe.value.slug) { return; } @@ -328,6 +332,7 @@ export default defineComponent({ parser, saveAll, createFood, + createUnit, deleteIngredient, insertIngredient, errors, diff --git a/mealie/db/models/_model_base.py b/mealie/db/models/_model_base.py index 0eeee9e843d3..6f44b608bae6 100644 --- a/mealie/db/models/_model_base.py +++ b/mealie/db/models/_model_base.py @@ -2,6 +2,7 @@ from datetime import datetime from sqlalchemy import DateTime, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from text_unidecode import unidecode class SqlAlchemyBase(DeclarativeBase): @@ -9,6 +10,10 @@ class SqlAlchemyBase(DeclarativeBase): created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.now, index=True) update_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now) + @classmethod + def normalize(cls, val: str) -> str: + return unidecode(val).lower().strip() + class BaseMixins: """ diff --git a/mealie/db/models/recipe/ingredient.py b/mealie/db/models/recipe/ingredient.py index 8813603b2d47..f95ededa7572 100644 --- a/mealie/db/models/recipe/ingredient.py +++ b/mealie/db/models/recipe/ingredient.py @@ -4,7 +4,6 @@ import sqlalchemy as sa from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, event, orm from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm.session import Session -from text_unidecode import unidecode from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models.labels import MultiPurposeLabel @@ -34,9 +33,56 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins): "RecipeIngredientModel", back_populates="unit" ) + # Automatically updated by sqlalchemy event, do not write to this manually + name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) + abbreviation_normalized: Mapped[str | None] = mapped_column(String, index=True) + @auto_init() - def __init__(self, **_) -> None: - pass + def __init__(self, session: Session, name: str | None = None, abbreviation: str | None = None, **_) -> None: + if name is not None: + self.name_normalized = self.normalize(name) + + if abbreviation is not None: + self.abbreviation = self.normalize(abbreviation) + + tableargs = [ + sa.Index( + "ix_ingredient_units_name_normalized", + "name_normalized", + unique=False, + ), + sa.Index( + "ix_ingredient_units_abbreviation_normalized", + "abbreviation_normalized", + unique=False, + ), + ] + + if session.get_bind().name == "postgresql": + tableargs.extend( + [ + sa.Index( + "ix_ingredient_units_name_normalized_gin", + "name_normalized", + unique=False, + postgresql_using="gin", + postgresql_ops={ + "name_normalized": "gin_trgm_ops", + }, + ), + sa.Index( + "ix_ingredient_units_abbreviation_normalized_gin", + "abbreviation_normalized", + unique=False, + postgresql_using="gin", + postgresql_ops={ + "abbreviation_normalized": "gin_trgm_ops", + }, + ), + ] + ) + + self.__table_args__ = tuple(tableargs) class IngredientFoodModel(SqlAlchemyBase, BaseMixins): @@ -57,10 +103,39 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins): label_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), index=True) label: Mapped[MultiPurposeLabel | None] = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="foods") + # Automatically updated by sqlalchemy event, do not write to this manually + name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) + @api_extras @auto_init() - def __init__(self, **_) -> None: - pass + def __init__(self, session: Session, name: str | None = None, **_) -> None: + if name is not None: + self.name_normalized = self.normalize(name) + + tableargs = [ + sa.Index( + "ix_ingredient_foods_name_normalized", + "name_normalized", + unique=False, + ), + ] + + if session.get_bind().name == "postgresql": + tableargs.extend( + [ + sa.Index( + "ix_ingredient_foods_name_normalized_gin", + "name_normalized", + unique=False, + postgresql_using="gin", + postgresql_ops={ + "name_normalized": "gin_trgm_ops", + }, + ) + ] + ) + + self.__table_args__ = tuple(tableargs) class RecipeIngredientModel(SqlAlchemyBase, BaseMixins): @@ -92,10 +167,10 @@ class RecipeIngredientModel(SqlAlchemyBase, BaseMixins): def __init__(self, session: Session, note: str | None = None, orginal_text: str | None = None, **_) -> None: # SQLAlchemy events do not seem to register things that are set during auto_init if note is not None: - self.note_normalized = unidecode(note).lower().strip() + self.note_normalized = self.normalize(note) if orginal_text is not None: - self.orginal_text = unidecode(orginal_text).lower().strip() + self.orginal_text = self.normalize(orginal_text) tableargs = [ # base set of indices sa.Index( @@ -136,17 +211,41 @@ class RecipeIngredientModel(SqlAlchemyBase, BaseMixins): self.__table_args__ = tuple(tableargs) -@event.listens_for(RecipeIngredientModel.note, "set") -def receive_note(target: RecipeIngredientModel, value: str, oldvalue, initiator): +@event.listens_for(IngredientUnitModel.name, "set") +def receive_unit_name(target: IngredientUnitModel, value: str | None, oldvalue, initiator): if value is not None: - target.note_normalized = unidecode(value).lower().strip() + target.name_normalized = IngredientUnitModel.normalize(value) + else: + target.name_normalized = None + + +@event.listens_for(IngredientUnitModel.abbreviation, "set") +def receive_unit_abbreviation(target: IngredientUnitModel, value: str | None, oldvalue, initiator): + if value is not None: + target.abbreviation_normalized = IngredientUnitModel.normalize(value) + else: + target.abbreviation_normalized = None + + +@event.listens_for(IngredientFoodModel.name, "set") +def receive_food_name(target: IngredientFoodModel, value: str | None, oldvalue, initiator): + if value is not None: + target.name_normalized = IngredientFoodModel.normalize(value) + else: + target.name_normalized = None + + +@event.listens_for(RecipeIngredientModel.note, "set") +def receive_ingredient_note(target: RecipeIngredientModel, value: str | None, oldvalue, initiator): + if value is not None: + target.note_normalized = RecipeIngredientModel.normalize(value) else: target.note_normalized = None @event.listens_for(RecipeIngredientModel.original_text, "set") -def receive_original_text(target: RecipeIngredientModel, value: str, oldvalue, initiator): +def receive_ingredient_original_text(target: RecipeIngredientModel, value: str | None, oldvalue, initiator): if value is not None: - target.original_text_normalized = unidecode(value).lower().strip() + target.original_text_normalized = RecipeIngredientModel.normalize(value) else: target.original_text_normalized = None diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index b181a232082b..ed559d3a6023 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -6,7 +6,6 @@ import sqlalchemy.orm as orm from sqlalchemy import event from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.orm import Mapped, mapped_column, validates -from text_unidecode import unidecode from mealie.db.models._model_utils.guid import GUID @@ -189,10 +188,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): # SQLAlchemy events do not seem to register things that are set during auto_init if name is not None: - self.name_normalized = unidecode(name).lower().strip() + self.name_normalized = self.normalize(name) if description is not None: - self.description_normalized = unidecode(description).lower().strip() + self.description_normalized = self.normalize(description) tableargs = [ # base set of indices sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"), @@ -237,12 +236,12 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): @event.listens_for(RecipeModel.name, "set") def receive_name(target: RecipeModel, value: str, oldvalue, initiator): - target.name_normalized = unidecode(value).lower().strip() + target.name_normalized = RecipeModel.normalize(value) @event.listens_for(RecipeModel.description, "set") def receive_description(target: RecipeModel, value: str, oldvalue, initiator): if value is not None: - target.description_normalized = unidecode(value).lower().strip() + target.description_normalized = RecipeModel.normalize(value) else: target.description_normalized = None diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py index fefaacdcb9cd..8a68290edf19 100644 --- a/mealie/repos/repository_generic.py +++ b/mealie/repos/repository_generic.py @@ -312,6 +312,10 @@ class RepositoryGeneric(Generic[Schema, Model]): if search: q = self.add_search_to_query(q, eff_schema, search) + if not pagination_result.order_by and not search: + # default ordering if not searching + pagination_result.order_by = "created_at" + q, count, total_pages = self.add_pagination_to_query(q, pagination_result) # Apply options late, so they do not get used for counting @@ -371,16 +375,14 @@ class RepositoryGeneric(Generic[Schema, Model]): if pagination.page < 1: pagination.page = 1 - if pagination.order_by: - query = self.add_order_by_to_query(query, pagination) - + query = self.add_order_by_to_query(query, pagination) return query.limit(pagination.per_page).offset((pagination.page - 1) * pagination.per_page), count, total_pages def add_order_by_to_query(self, query: Select, pagination: PaginationQuery) -> Select: if not pagination.order_by: return query - if pagination.order_by == "random": + elif pagination.order_by == "random": # randomize outside of database, since not all db's can set random seeds # this solution is db-independent & stable to paging temp_query = query.with_only_columns(self.model.id) diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index 6ab555d5882a..31b52aac2361 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -203,6 +203,10 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): if search: q = self.add_search_to_query(q, self.schema, search) + if not pagination_result.order_by and not search: + # default ordering if not searching + pagination_result.order_by = "created_at" + q, count, total_pages = self.add_pagination_to_query(q, pagination_result) try: diff --git a/mealie/routes/parser/ingredient_parser.py b/mealie/routes/parser/ingredient_parser.py index d68437bec953..0a63fa845deb 100644 --- a/mealie/routes/parser/ingredient_parser.py +++ b/mealie/routes/parser/ingredient_parser.py @@ -12,10 +12,10 @@ router = APIRouter(prefix="/parser") class IngredientParserController(BaseUserController): @router.post("/ingredients", response_model=list[ParsedIngredient]) def parse_ingredients(self, ingredients: IngredientsRequest): - parser = get_parser(ingredients.parser) + parser = get_parser(ingredients.parser, self.group_id, self.session) return parser.parse(ingredients.ingredients) @router.post("/ingredient", response_model=ParsedIngredient) def parse_ingredient(self, ingredient: IngredientRequest): - parser = get_parser(ingredient.parser) + parser = get_parser(ingredient.parser, self.group_id, self.session) return parser.parse([ingredient.ingredient])[0] diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index 36006b9c1eb7..468a041d4a1e 100644 --- a/mealie/schema/recipe/recipe_ingredient.py +++ b/mealie/schema/recipe/recipe_ingredient.py @@ -51,7 +51,8 @@ class IngredientFood(CreateIngredientFood): created_at: datetime.datetime | None update_at: datetime.datetime | None - _searchable_properties: ClassVar[list[str]] = ["name", "description"] + _searchable_properties: ClassVar[list[str]] = ["name_normalized"] + _normalize_search: ClassVar[bool] = True class Config: orm_mode = True @@ -81,7 +82,8 @@ class IngredientUnit(CreateIngredientUnit): created_at: datetime.datetime | None update_at: datetime.datetime | None - _searchable_properties: ClassVar[list[str]] = ["name", "abbreviation", "description"] + _searchable_properties: ClassVar[list[str]] = ["name_normalized", "abbreviation_normalized"] + _normalize_search: ClassVar[bool] = True class Config: orm_mode = True diff --git a/mealie/schema/response/pagination.py b/mealie/schema/response/pagination.py index fdde83f1073b..74e3b1db1cd5 100644 --- a/mealie/schema/response/pagination.py +++ b/mealie/schema/response/pagination.py @@ -34,7 +34,7 @@ class RecipeSearchQuery(MealieModel): class PaginationQuery(MealieModel): page: int = 1 per_page: int = 50 - order_by: str = "created_at" + order_by: str | None = None order_by_null_position: OrderByNullPosition | None = None order_direction: OrderDirection = OrderDirection.desc query_filter: str | None = None diff --git a/mealie/services/parser_services/ingredient_parser.py b/mealie/services/parser_services/ingredient_parser.py index 36d56c064385..71f99284f089 100644 --- a/mealie/services/parser_services/ingredient_parser.py +++ b/mealie/services/parser_services/ingredient_parser.py @@ -1,20 +1,32 @@ from abc import ABC, abstractmethod from fractions import Fraction +from typing import TypeVar + +from pydantic import UUID4, BaseModel +from rapidfuzz import fuzz, process +from sqlalchemy.orm import Session from mealie.core.root_logger import get_logger +from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel +from mealie.repos.all_repositories import get_repositories +from mealie.repos.repository_factory import AllRepositories from mealie.schema.recipe import RecipeIngredient from mealie.schema.recipe.recipe_ingredient import ( MAX_INGREDIENT_DENOMINATOR, CreateIngredientFood, CreateIngredientUnit, IngredientConfidence, + IngredientFood, + IngredientUnit, ParsedIngredient, RegisteredParser, ) +from mealie.schema.response.pagination import PaginationQuery from . import brute, crfpp logger = get_logger(__name__) +T = TypeVar("T", bound=BaseModel) class ABCIngredientParser(ABC): @@ -22,6 +34,53 @@ class ABCIngredientParser(ABC): Abstract class for ingredient parsers. """ + def __init__(self, group_id: UUID4, session: Session) -> None: + self.group_id = group_id + self.session = session + + self._foods_by_name: dict[str, IngredientFood] | None = None + self._units_by_name: dict[str, IngredientUnit] | None = None + + @property + def _repos(self) -> AllRepositories: + return get_repositories(self.session) + + @property + def foods_by_normalized_name(self) -> dict[str, IngredientFood]: + if self._foods_by_name is None: + foods_repo = self._repos.ingredient_foods.by_group(self.group_id) + + query = PaginationQuery(page=1, per_page=-1) + all_foods = foods_repo.page_all(query).items + self._foods_by_name = {IngredientFoodModel.normalize(food.name): food for food in all_foods if food.name} + + return self._foods_by_name + + @property + def units_by_normalized_name_or_abbreviation(self) -> dict[str, IngredientUnit]: + if self._units_by_name is None: + units_repo = self._repos.ingredient_units.by_group(self.group_id) + + query = PaginationQuery(page=1, per_page=-1) + all_units = units_repo.page_all(query).items + self._units_by_name = { + IngredientUnitModel.normalize(unit.name): unit for unit in all_units if unit.name + } | {IngredientUnitModel.normalize(unit.abbreviation): unit for unit in all_units if unit.abbreviation} + + return self._units_by_name + + @property + def food_fuzzy_match_threshold(self) -> int: + """Minimum threshold to fuzzy match against a database food search""" + + return 85 + + @property + def unit_fuzzy_match_threshold(self) -> int: + """Minimum threshold to fuzzy match against a database unit search""" + + return 70 + @abstractmethod def parse_one(self, ingredient_string: str) -> ParsedIngredient: ... @@ -30,19 +89,64 @@ class ABCIngredientParser(ABC): def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: ... + @classmethod + def find_match(cls, match_value: str, *, store_map: dict[str, T], fuzzy_match_threshold: int = 0) -> T | None: + # check for literal matches + if match_value in store_map: + return store_map[match_value] + + # fuzzy match against food store + fuzz_result = process.extractOne(match_value, store_map.keys(), scorer=fuzz.ratio) + if fuzz_result is None: + return None + + choice, score, _ = fuzz_result + if score < fuzzy_match_threshold: + return None + else: + return store_map[choice] + + def find_food_match(self, food: IngredientFood | CreateIngredientFood) -> IngredientFood | None: + if isinstance(food, IngredientFood): + return food + + match_value = IngredientFoodModel.normalize(food.name) + return self.find_match( + match_value, + store_map=self.foods_by_normalized_name, + fuzzy_match_threshold=self.food_fuzzy_match_threshold, + ) + + def find_unit_match(self, unit: IngredientUnit | CreateIngredientUnit) -> IngredientUnit | None: + if isinstance(unit, IngredientUnit): + return unit + + match_value = IngredientUnitModel.normalize(unit.name) + return self.find_match( + match_value, + store_map=self.units_by_normalized_name_or_abbreviation, + fuzzy_match_threshold=self.unit_fuzzy_match_threshold, + ) + + def find_ingredient_match(self, ingredient: ParsedIngredient) -> ParsedIngredient: + if ingredient.ingredient.food and (food_match := self.find_food_match(ingredient.ingredient.food)): + ingredient.ingredient.food = food_match + + if ingredient.ingredient.unit and (unit_match := self.find_unit_match(ingredient.ingredient.unit)): + ingredient.ingredient.unit = unit_match + + return ingredient + class BruteForceParser(ABCIngredientParser): """ Brute force ingredient parser. """ - def __init__(self) -> None: - pass - def parse_one(self, ingredient: str) -> ParsedIngredient: bfi = brute.parse(ingredient) - return ParsedIngredient( + parsed_ingredient = ParsedIngredient( input=ingredient, ingredient=RecipeIngredient( unit=CreateIngredientUnit(name=bfi.unit), @@ -53,6 +157,8 @@ class BruteForceParser(ABCIngredientParser): ), ) + return self.find_ingredient_match(parsed_ingredient) + def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: return [self.parse_one(ingredient) for ingredient in ingredients] @@ -62,9 +168,6 @@ class NLPParser(ABCIngredientParser): Class for CRFPP ingredient parsers. """ - def __init__(self) -> None: - pass - def _crf_to_ingredient(self, crf_model: crfpp.CRFIngredient) -> ParsedIngredient: ingredient = None @@ -87,7 +190,7 @@ class NLPParser(ABCIngredientParser): note=crf_model.input, ) - return ParsedIngredient( + parsed_ingredient = ParsedIngredient( input=crf_model.input, ingredient=ingredient, confidence=IngredientConfidence( @@ -97,6 +200,8 @@ class NLPParser(ABCIngredientParser): ), ) + return self.find_ingredient_match(parsed_ingredient) + def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: crf_models = crfpp.convert_list_to_crf_model(ingredients) return [self._crf_to_ingredient(crf_model) for crf_model in crf_models] @@ -112,9 +217,9 @@ __registrar = { } -def get_parser(parser: RegisteredParser) -> ABCIngredientParser: +def get_parser(parser: RegisteredParser, group_id: UUID4, session: Session) -> ABCIngredientParser: """ get_parser returns an ingrdeint parser based on the string enum value passed in. """ - return __registrar.get(parser, NLPParser)() + return __registrar.get(parser, NLPParser)(group_id, session) diff --git a/poetry.lock b/poetry.lock index e5205be6284d..a5f92e56217f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -605,6 +605,7 @@ files = [ {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, + {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c"}, {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, @@ -613,6 +614,7 @@ files = [ {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, + {file = "greenlet-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, @@ -642,6 +644,7 @@ files = [ {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, + {file = "greenlet-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, @@ -650,6 +653,7 @@ files = [ {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, + {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417"}, {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, @@ -2036,6 +2040,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -2043,8 +2048,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -2061,6 +2073,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -2068,6 +2081,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -2087,6 +2101,110 @@ files = [ [package.dependencies] pyyaml = "*" +[[package]] +name = "rapidfuzz" +version = "3.2.0" +description = "rapid fuzzy string matching" +optional = false +python-versions = ">=3.7" +files = [ + {file = "rapidfuzz-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f5787f1cc456207dee1902804209e1a90df67e88517213aeeb1b248822413b4c"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e8d91137b0b5a6ef06c3979b6302265129dee1741486b6baa241ac63a632bea7"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c130e73e0079f403b7c3dbf6f85816a3773971c3e639f7289f8b4337b8fd70fe"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e18059188bfe3cdbc3462aeec2fa3302b08717e04ca34e2cc6e02fb3c0280d8"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:37bb6bd6a79d5524f121ff2a7d7df4491519b3f43565dccd4596bd75aa73ab7c"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca0d6aee42effaf2e8883d2181196dd0957b1af5731b0763f10f994c32c823db"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49fc2cbbf05bfa1af3fe4c0e0c8e5c8ac118d6b6ddfb0081cff48ad53734f7ac"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bd4fdee46f6ba7d254dba8e7e8f33012c964fc891a06b036b0fd20cab0db301"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab2863732eafd1cc58f249f145c20ad13d4c902d3ef3a369b00438c05e5bfb55"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a9658c545de62ac948027092ba7f4e8507ebc5c9aef964eca654409c58f207f0"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5f3e36cfadaf29f081ad4ca476e320b639d610e930e0557f395780c9b2bdb135"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:239ffc04328e14f5e4097102bd934352a43d5912acf34fb7d3e3fe306de92787"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b56ce39ba0a77501d491bc20a2266989ae0264452758b004950ee5f4c10c641f"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-win32.whl", hash = "sha256:dbebd639579ab113644699fe0c536ae00aba15b224e40a79987684333d1104a5"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:88e99229c4df99a7e5810d4d361033b44e29d8eb4faaddcfb8e4bdcb604cf40a"}, + {file = "rapidfuzz-3.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:8e39c4e2e85828aa6c39cc7f30e2917d991b40190a2a3af1fa02396a3362a54e"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2f2e618389427c5e8304357a78f83df22558e61f11bc21aeb95dd544c274d330"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a2a6babfe4d3ce2eadd0079ee7861cb5f1584845c5a3394edead85457e7d7464"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f223deb06895c9c136b40cd8fd7e96ee745c3bb9ed502d7367f6ad9ab6fdd40e"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0de6962b45f761355fa4b37de635e4df467d57530732a40d82e748a5bc911731"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76953516cb3b75fb1234c5a90e0b86be4525f055a9e276237adb1ffe40dca536"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1e04861dddbb477500449dc67fb037656a049b6f78c4c434c6000e64aa42bb4"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff6e725eec9c769f9d22126c80a6ada90275c0d693eca2b35d5933178bda5a2"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f21ce33242e579ba255c8a8b438782164acaa55bf188d9410298c40cbaa07d5"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:986a7aad18768b920bb710e15ed7629d1da0af31589348c0a51d152820efc05d"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6e98f0a6fac14b7b9893147deceae12131f6ff169ae1c973635ef97617949c8f"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5dd5c4b9f5cd8a8271a90d1bab643028e7172808c68ed5d8dde661a3e51098e3"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:e336b0a81c5a8e689edf6928136d19e791733a66509026d9acbaa148238186e0"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8fa44afb731535a803c4c15ee846257fef050768af96d1d6c0eadb30285d0f7b"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-win32.whl", hash = "sha256:d04ad155dbecc0c143912f691d38d4790e290c2ce5411b146c0e00d4f4afd26f"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:b9e79e27344af95a71a3bb6cd3562581da5d0780ff847a13ad69ee622d940d3c"}, + {file = "rapidfuzz-3.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:dc53747e73f34e8f3a3c1b0bc5b437b90a2c69d873e97781aa7c06543201409a"}, + {file = "rapidfuzz-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:613c1043332eeba0c0910de71af221ac10d820b4fa9615b0083c733b90a757f9"}, + {file = "rapidfuzz-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0907f87beca70e44f78e318eede2416ddba19ec43d28af9248617e8a1741ef3"}, + {file = "rapidfuzz-3.2.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcfd184e0b5c58497cc3d961f49ac07ae1656d161c6c4d06230d267ae4e11f00"}, + {file = "rapidfuzz-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7a7d53a2f1ccfb169be26fa3824b1b185420592c75853f16c6b7115315ea6784"}, + {file = "rapidfuzz-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2eac585803c4e8132ed5f4a150621db05c418304982c88cf706abdded65e1632"}, + {file = "rapidfuzz-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc859f654b350def5df2ebc6d09f822b04399823e3dad1c3f2e8776c825fcde7"}, + {file = "rapidfuzz-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8a165f64c528edc0bbbd09c76d64efd4dbe4240fd1961710b69586ef40486e79"}, + {file = "rapidfuzz-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:56a392b655597ecf40535b56bfb7c0856c10c0abc0cbc369fd25a1665420710b"}, + {file = "rapidfuzz-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:5863b176da42b1bb450a28375ef1502f81fbecd210a5aae295d7f2221284ad41"}, + {file = "rapidfuzz-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:8f8590c39a3f745b314f2697b140c8f8600fe7ecfb2101e9e4ec6e7716c66827"}, + {file = "rapidfuzz-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:da00990adf1fbc0904f22409b3451473fa465a0ef49f3075703c206080aa31b2"}, + {file = "rapidfuzz-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:2504205552bf568ac478f17dd612d0e31c4a82c645c66209a442df7e572b5adc"}, + {file = "rapidfuzz-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:af3ac648232c109e36c8b941106d726969972644aa3ef55218c5988aa1daea03"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:04d22f6058ce5d620ec4ecd771e44cfa77d571137d6c6547df57bdfc44ee2a98"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac7ddcd372ed202d1b59b117506da695b291f135435cfbf3e71490aa8e687173"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fd3fca0224b84350f73eab1fb5728c58fd25ee4f20e512607c7d83f9bc836d3f"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bdb1f92c4666c7e1d3c21268b931cf3f06f32af98dfdeb37641159b15fa31dd"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:871052405c465a45b53a3dc854a8be62079f42cdbb052651ff0b65e2452131e6"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb9bb1af5680741cf974f510fb3894907a1b308e819aff3d9ea10b5326e8a5f6"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84ce2e010677835fa5ba591419e4404f11a1446f33eec3724a2bff557ae5144a"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c13107e0fdca5ccae70659f45646d57453338a9dfc6b152fb7372e4bf73466a0"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:538027685a1a8f1699e329f6443951267f169bfa149298734ea679db8f0e7171"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3557736672115d082979a8a12f884ed5b24268f4471fee85cfb2ec7212b68607"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6bc5e3da74644cf75663f5b438e0ae79b67d1f96d082cda771b0ecfed0528f40"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:d2d0fc98d9d7bba44f929d201c2c2c35eb69ea2ffef43d939b297dafef934625"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2bf85a3bf34f27383691e8af0fd148b2a3a89f1444d4640d04ef58030f596ee0"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-win32.whl", hash = "sha256:cf5ea3f1d65a0bee707245a0096c3a6f769b3ad6f1b9afc7176dfb73eb0ac98f"}, + {file = "rapidfuzz-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:54906095444ea8b0a4013f3799b3f2c380205d7f60b9c55774e7d2264fa8d9c6"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6d44218823533e0d47770feef86c73c90a6f7e8d4923eafabf56a1fa3444eda0"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:87c3d4077e61c66d5dd11198a317f83db8e8cf034239baa16e4384037b611652"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fc0e1142350566349c41173685988d942ebc89578f25ee27750d261e7d79e1ce"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de44a378751fdfb19ddf6af412b3395db4b21ab61f40139f815c82f1a1611b50"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca0983b30c7b289f540b11cdb550e301b3f2e8f0ef9df866aa24a16f6cd96041"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adfffb79288437006be412d74e28cddd7c5e6cc9f84a34aa9c356b13dc1ad2c9"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a284386652efb3b7d41ed5dd101ab4ce5936f585c52a47fa9838fc0342235700"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c546c83d6bc9006b86f56921b92c3e16d8ddeb4e1663653e755a5d8a3ac258da"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:53b3575fa398a5021192c1592dce98965560ad00690be3ade056eab99288562c"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:366ade5d0067dc6281e2a6c9e5c91bbfe023b09cef86894de8fe480b4696e3bf"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f946dec03cc2c77bc091d186c007d1e957d1f16a4d68a181f5fa75aea40bdf87"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:045e5cccb0e792005d5465de0ea4621b9b67778580e558f266984704e68b0087"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fd80288b9538c87209893f0934563c20b6a43acf30693794bcc111b294447ee9"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-win32.whl", hash = "sha256:a359436754ed5dd10d88706f076caa7f8e5c1469bf5ebba1897dc87aa9ff953e"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:75df3d9b895910ee810b2c96c8626cc2b5b63bb237762db36ff79fb466eccc43"}, + {file = "rapidfuzz-3.2.0-cp39-cp39-win_arm64.whl", hash = "sha256:893833a903875a50acdbcb7ed33b5426ba47412bd18b3eb80d56d982b641dc59"}, + {file = "rapidfuzz-3.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3002c3660180747243cccb40c95ade1960e6665b340f211a114f5994b345ab53"}, + {file = "rapidfuzz-3.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa50de7e0f95e1400b2bf38cfeb6e40cf87c862537871c2f7b2050b5db0a9dfc"}, + {file = "rapidfuzz-3.2.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54842a578a2a8e5258812a9032ffb55e6f1185490fd160cae64e57b4dc342297"}, + {file = "rapidfuzz-3.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:108861623838cd574b0faa3309ce8525c2086159de7f9e23ac263a987c070ebd"}, + {file = "rapidfuzz-3.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:d39128415f0b52be08c15eeee5f79288189933a4d6fa5dc5fff11e20614b7989"}, + {file = "rapidfuzz-3.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3af2b75635f33ffab84e295773c84a176d4cba75311d836ad79b6795e9da11ac"}, + {file = "rapidfuzz-3.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68c678f7f3ca3d83d1e1dd7fb7db3232037d9eef12a47f1d5fe248a76ca47571"}, + {file = "rapidfuzz-3.2.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25d2bd257034e910df0951cdeff337dbd086d7d90af3ed9f6721e7bba9fc388a"}, + {file = "rapidfuzz-3.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c7f20e68cad26fc140c6f2ac9e8f2632a0cd66e407ba3ea4ace63c669fd4719"}, + {file = "rapidfuzz-3.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:f09fd9dc73180deb9ca1c4fbd9cc27378f0ab6ee74e97318c38c5080708702b6"}, + {file = "rapidfuzz-3.2.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af7914fc7683f921492f32314cfbe915a5376cc08a982e09084cbd9b866c9fd4"}, + {file = "rapidfuzz-3.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08a242c4b909abbcfa44504dc5041d5eeca4cd088ae51afd6a52b4dc61684fa2"}, + {file = "rapidfuzz-3.2.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71b07afaca28398b93d727a2565491c455896898b66daee4664acde4af94e557"}, + {file = "rapidfuzz-3.2.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24e4c4a031c50e4eeb4787263319a0ac5bed20f4a263d28eac060150e3ba0018"}, + {file = "rapidfuzz-3.2.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d19c2853a464c7b98cc408654412fd875b030f78023ccbefc4ba9eec754e07e7"}, + {file = "rapidfuzz-3.2.0.tar.gz", hash = "sha256:448d031d9960fea7826d42bd4284156fc68d3b55a6946eb34ca5c6acf960577b"}, +] + +[package.extras] +full = ["numpy"] + [[package]] name = "rdflib" version = "6.2.0" @@ -2933,4 +3051,4 @@ pgsql = ["psycopg2-binary"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "b18a48b2cf3cf26a5eea456056c9dcaf527fb9a6aabf493f25fedb73917175c5" +content-hash = "e338e2d0ab5605c2096788cca6e344eead299963335f4ed9f919b2e3f3cb6d3f" diff --git a/pyproject.toml b/pyproject.toml index bea70eac017f..166867881591 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ uvicorn = {extras = ["standard"], version = "^0.21.0"} beautifulsoup4 = "^4.11.2" isodate = "^0.6.1" text-unidecode = "^1.3" +rapidfuzz = "^3.2.0" [tool.poetry.group.dev.dependencies] black = "^23.7.0" diff --git a/tests/unit_tests/repository_tests/test_search.py b/tests/unit_tests/repository_tests/test_search.py index f79398f8a02a..a71ea50ff424 100644 --- a/tests/unit_tests/repository_tests/test_search.py +++ b/tests/unit_tests/repository_tests/test_search.py @@ -25,13 +25,11 @@ def search_units(database: AllRepositories, unique_local_group_id: str) -> list[ SaveIngredientUnit( group_id=unique_local_group_id, name="Table Spoon", - description="unique description", abbreviation="tbsp", ), SaveIngredientUnit( group_id=unique_local_group_id, name="Cup", - description="A bucket that's full", ), SaveIngredientUnit( group_id=unique_local_group_id, @@ -45,6 +43,10 @@ def search_units(database: AllRepositories, unique_local_group_id: str) -> list[ group_id=unique_local_group_id, name="Unit with a pretty cool name", ), + SaveIngredientUnit( + group_id=unique_local_group_id, + name="Unit with a correct horse battery staple", + ), ] # Add a bunch of units for stable randomization @@ -64,16 +66,14 @@ def search_units(database: AllRepositories, unique_local_group_id: str) -> list[ (random_string(), []), ("Cup", ["Cup"]), ("tbsp", ["Table Spoon"]), - ("unique description", ["Table Spoon"]), ("very cool name", ["Unit with a very cool name", "Unit with a pretty cool name"]), ('"Tea Spoon"', ["Tea Spoon"]), - ("full bucket", ["Cup"]), + ("correct staple", ["Unit with a correct horse battery staple"]), ], ids=[ "no_match", "search_by_name", "search_by_unit", - "search_by_description", "match_order", "literal_search", "token_separation", @@ -110,7 +110,7 @@ def test_fuzzy_search( repo = database.ingredient_units.by_group(unique_local_group_id) pagination = PaginationQuery(page=1, per_page=-1, order_by="created_at", order_direction=OrderDirection.asc) - results = repo.page_all(pagination, search="unique decsription").items + results = repo.page_all(pagination, search="tabel spoone").items assert results and results[0].name == "Table Spoon" diff --git a/tests/unit_tests/test_ingredient_parser.py b/tests/unit_tests/test_ingredient_parser.py index 66615687e5ec..4128facfa8f1 100644 --- a/tests/unit_tests/test_ingredient_parser.py +++ b/tests/unit_tests/test_ingredient_parser.py @@ -3,9 +3,25 @@ from dataclasses import dataclass from fractions import Fraction import pytest +from pydantic import UUID4 +from mealie.db.db_setup import session_context +from mealie.repos.repository_factory import AllRepositories +from mealie.schema.recipe.recipe_ingredient import ( + CreateIngredientFood, + CreateIngredientUnit, + IngredientFood, + IngredientUnit, + ParsedIngredient, + RecipeIngredient, + SaveIngredientFood, + SaveIngredientUnit, +) +from mealie.schema.user.user import GroupBase from mealie.services.parser_services import RegisteredParser, get_parser from mealie.services.parser_services.crfpp.processor import CRFIngredient, convert_list_to_crf_model +from tests.utils.factories import random_int, random_string +from tests.utils.fixture_schemas import TestUser @dataclass @@ -21,6 +37,70 @@ def crf_exists() -> bool: return shutil.which("crf_test") is not None +def build_parsed_ing(food: str | None, unit: str | None) -> ParsedIngredient: + ing = RecipeIngredient(unit=None, food=None) + if food: + ing.food = CreateIngredientFood(name=food) + if unit: + ing.unit = CreateIngredientUnit(name=unit) + + return ParsedIngredient(input=None, ingredient=ing) + + +@pytest.fixture() +def unique_local_group_id(database: AllRepositories) -> UUID4: + return str(database.groups.create(GroupBase(name=random_string())).id) + + +@pytest.fixture() +def parsed_ingredient_data( + database: AllRepositories, unique_local_group_id: UUID4 +) -> tuple[list[IngredientFood], list[IngredientUnit]]: + foods = database.ingredient_foods.create_many( + [ + SaveIngredientFood(name="potatoes", group_id=unique_local_group_id), + SaveIngredientFood(name="onion", group_id=unique_local_group_id), + SaveIngredientFood(name="green onion", group_id=unique_local_group_id), + SaveIngredientFood(name="frozen pearl onions", group_id=unique_local_group_id), + SaveIngredientFood(name="bell peppers", group_id=unique_local_group_id), + SaveIngredientFood(name="red pepper flakes", group_id=unique_local_group_id), + SaveIngredientFood(name="fresh ginger", group_id=unique_local_group_id), + SaveIngredientFood(name="ground ginger", group_id=unique_local_group_id), + SaveIngredientFood(name="ñör̃m̈ãl̈ĩz̈ẽm̈ẽ", group_id=unique_local_group_id), + ] + ) + + foods.extend( + database.ingredient_foods.create_many( + [ + SaveIngredientFood(name=f"{random_string()} food", group_id=unique_local_group_id) + for _ in range(random_int(10, 15)) + ] + ) + ) + + units = database.ingredient_units.create_many( + [ + SaveIngredientUnit(name="Cups", group_id=unique_local_group_id), + SaveIngredientUnit(name="Tablespoon", group_id=unique_local_group_id), + SaveIngredientUnit(name="Teaspoon", group_id=unique_local_group_id), + SaveIngredientUnit(name="Stalk", group_id=unique_local_group_id), + SaveIngredientUnit(name="My Very Long Unit Name", abbreviation="mvlun", group_id=unique_local_group_id), + ] + ) + + units.extend( + database.ingredient_foods.create_many( + [ + SaveIngredientUnit(name=f"{random_string()} unit", group_id=unique_local_group_id) + for _ in range(random_int(10, 15)) + ] + ) + ) + + return foods, units + + # TODO - add more robust test cases test_ingredients = [ TestIngredient("½ cup all-purpose flour", 0.5, "cup", "all-purpose flour", ""), @@ -47,7 +127,7 @@ def test_nlp_parser(): assert model.unit == test_ingredient.unit -def test_brute_parser(): +def test_brute_parser(unique_user: TestUser): # input: (quantity, unit, food, comments) expectations = { # Dutch @@ -67,12 +147,161 @@ def test_brute_parser(): "fresh or frozen", ), } - parser = get_parser(RegisteredParser.brute) - for key, val in expectations.items(): - parsed = parser.parse_one(key) + with session_context() as session: + parser = get_parser(RegisteredParser.brute, unique_user.group_id, session) - assert parsed.ingredient.quantity == val[0] - assert parsed.ingredient.unit.name == val[1] - assert parsed.ingredient.food.name == val[2] - assert parsed.ingredient.note in {val[3], None} + for key, val in expectations.items(): + parsed = parser.parse_one(key) + + assert parsed.ingredient.quantity == val[0] + assert parsed.ingredient.unit.name == val[1] + assert parsed.ingredient.food.name == val[2] + assert parsed.ingredient.note in {val[3], None} + + +@pytest.mark.parametrize( + "input, expected_unit_name, expected_food_name, expect_unit_match, expect_food_match", + ( + pytest.param( + build_parsed_ing(unit="cup", food="potatoes"), + "Cups", + "potatoes", + True, + True, + id="basic match", + ), + pytest.param( # this should work in sqlite since "potato" is contained within "potatoes" + build_parsed_ing(unit="cup", food="potato"), + "Cups", + "potatoes", + True, + True, + id="basic fuzzy match", + ), + pytest.param( + build_parsed_ing(unit="tablespoon", food="onion"), + "Tablespoon", + "onion", + True, + True, + id="nested match 1", + ), + pytest.param( + build_parsed_ing(unit="teaspoon", food="green onion"), + "Teaspoon", + "green onion", + True, + True, + id="nested match 2", + ), + pytest.param( + build_parsed_ing(unit="cup", food="gren onion"), + "Cups", + "green onion", + True, + True, + id="nested match 3", + ), + pytest.param( + build_parsed_ing(unit="stalk", food="very unique"), + "Stalk", + "very unique", + True, + False, + id="no food match", + ), + pytest.param( + build_parsed_ing(unit="cup", food=None), + "Cups", + None, + True, + False, + id="no food input", + ), + pytest.param( + build_parsed_ing(unit="very unique", food="fresh ginger"), + "very unique", + "fresh ginger", + False, + True, + id="no unit match", + ), + pytest.param( + build_parsed_ing(unit=None, food="potatoes"), + None, + "potatoes", + False, + True, + id="no unit input", + ), + pytest.param( + build_parsed_ing(unit="very unique", food="very unique"), + "very unique", + "very unique", + False, + False, + id="no matches", + ), + pytest.param( + build_parsed_ing(unit=None, food=None), + None, + None, + False, + False, + id="no input", + ), + pytest.param( + build_parsed_ing(unit="mvlun", food="potatoes"), + "My Very Long Unit Name", + "potatoes", + True, + True, + id="unit abbreviation", + ), + pytest.param( + build_parsed_ing(unit=None, food="n̅ōr̅m̄a̅l̄i̅z̄e̅m̄e̅"), + None, + "ñör̃m̈ãl̈ĩz̈ẽm̈ẽ", + False, + True, + id="normalization", + ), + ), +) +def test_parser_ingredient_match( + expected_food_name: str | None, + expected_unit_name: str | None, + expect_food_match: bool, + expect_unit_match: bool, + input: ParsedIngredient, + parsed_ingredient_data: tuple[list[IngredientFood], list[IngredientUnit]], # required so database is populated + unique_local_group_id: UUID4, +): + with session_context() as session: + parser = get_parser(RegisteredParser.brute, unique_local_group_id, session) + parsed_ingredient = parser.find_ingredient_match(input) + + if expected_food_name: + assert parsed_ingredient.ingredient.food and parsed_ingredient.ingredient.food.name == expected_food_name + else: + assert parsed_ingredient.ingredient.food is None + + if expect_food_match: + assert isinstance(parsed_ingredient.ingredient.food, IngredientFood) + else: + assert parsed_ingredient.ingredient.food is None or isinstance( + parsed_ingredient.ingredient.food, CreateIngredientFood + ) + + if expected_unit_name: + assert parsed_ingredient.ingredient.unit and parsed_ingredient.ingredient.unit.name == expected_unit_name + else: + assert parsed_ingredient.ingredient.unit is None + + if expect_unit_match: + assert isinstance(parsed_ingredient.ingredient.unit, IngredientUnit) + else: + assert parsed_ingredient.ingredient.unit is None or isinstance( + parsed_ingredient.ingredient.unit, CreateIngredientUnit + )