mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	feat: Improved Ingredient Matching (#2535)
* added normalization to foods and units
* changed search to reference new normalized fields
* fix tests
* added parsed food matching to backend
* prevent pagination from ordering when searching
* added extra fuzzy matching to sqlite ing matching
* added tests
* only apply search ordering when order_by is null
* enabled post-search fuzzy matching for postgres
* fixed postgres fuzzy search test
* idk why this is failing
* 🤦
* simplified frontend ing matching
and restored automatic unit creation
* tightened food fuzzy threshold
* change to rapidfuzz
* sped up fuzzy matching with process
* fixed units not matching by abbreviation
* fast return for exact matches
* replace db searching with pure fuzz
* added fuzzy normalization
* tightened unit fuzzy matching thresh
* cleaned up comments/var names
* ran matching logic through the dryer
* oops
* simplified order by application logic
			
			
This commit is contained in:
		
							
								
								
									
										71
									
								
								alembic/versions/2023-09-01-14.55.42_0341b154f79a_.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								alembic/versions/2023-09-01-14.55.42_0341b154f79a_.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ### | ||||
| @@ -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(); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -13,9 +13,9 @@ interface PublicStoreActions<T extends BoundT> { | ||||
| } | ||||
|  | ||||
| interface StoreActions<T extends BoundT> extends PublicStoreActions<T> { | ||||
|   createOne(createData: T): Promise<void>; | ||||
|   updateOne(updateData: T): Promise<void>; | ||||
|   deleteOne(id: string | number): Promise<void>; | ||||
|   createOne(createData: T): Promise<T | null>; | ||||
|   updateOne(updateData: T): Promise<T | null>; | ||||
|   deleteOne(id: string | number): Promise<T | null>; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -121,31 +121,34 @@ export function useStoreActions<T extends BoundT>( | ||||
|     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 { | ||||
|   | ||||
| @@ -68,7 +68,15 @@ | ||||
|             <RecipeIngredientEditor v-model="parsedIng[index].ingredient" allow-insert-ingredient @insert-ingredient="insertIngredient(index)"  @delete="deleteIngredient(index)" /> | ||||
|             {{ ing.input }} | ||||
|             <v-card-actions> | ||||
|               <v-spacer></v-spacer> | ||||
|               <v-spacer /> | ||||
|               <BaseButton | ||||
|                 v-if="errors[index].unitError && errors[index].unitErrorMessage !== ''" | ||||
|                 color="warning" | ||||
|                 small | ||||
|                 @click="createUnit(ing.ingredient.unit, index)" | ||||
|               > | ||||
|                 {{ errors[index].unitErrorMessage }} | ||||
|               </BaseButton> | ||||
|               <BaseButton | ||||
|                 v-if="errors[index].foodError && errors[index].foodErrorMessage !== ''" | ||||
|                 color="warning" | ||||
| @@ -99,7 +107,7 @@ import { | ||||
| import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue"; | ||||
| import { useUserApi } from "~/composables/api"; | ||||
| import { useRecipe } from "~/composables/recipes"; | ||||
| import { useFoodData, useFoodStore, useUnitStore } from "~/composables/store"; | ||||
| import { useFoodData, useFoodStore, useUnitStore, useUnitData } from "~/composables/store"; | ||||
| import { Parser } from "~/lib/api/user/recipes/recipe"; | ||||
| import { uuid4 } from "~/composables/use-utils"; | ||||
|  | ||||
| @@ -215,30 +223,19 @@ export default defineComponent({ | ||||
|  | ||||
|     const foodStore = useFoodStore(); | ||||
|     const foodData = useFoodData(); | ||||
|     const { units } = useUnitStore(); | ||||
|     const unitStore = useUnitStore(); | ||||
|     const unitData = useUnitData(); | ||||
|  | ||||
|     const errors = ref<Error[]>([]); | ||||
|  | ||||
|     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, | ||||
|   | ||||
| @@ -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: | ||||
|     """ | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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] | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
							
								
								
									
										120
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										120
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							| @@ -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" | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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" | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|             ) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user