mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -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:
parent
084ad4228b
commit
2dfbe9f08d
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
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user