Feature/CRF++ and server side locales (#731)

* add universal toast plugin

* add server side locales

* integrate CRF++ into CI/CD Pipeline

* docs(docs): 📝 add recipe parser docs

* feat(backend):  Continued work on ingredient parsers

* add new model dest

* feat(frontend):  New ingredient parser page

* formatting

Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
Hayden
2021-10-09 13:08:23 -08:00
committed by GitHub
parent c16f07950f
commit 60908e5a88
43 changed files with 610 additions and 186 deletions

View File

@@ -9,6 +9,7 @@ from mealie.core.config import get_app_dirs, get_app_settings
from mealie.core.root_logger import get_logger
from mealie.db.database import get_database
from mealie.db.db_setup import SessionLocal
from mealie.lang import get_locale_provider
from mealie.schema.user.user import PrivateUser
logger = get_logger()
@@ -64,10 +65,11 @@ class BaseHttpService(Generic[T, D], ABC):
self.db = get_database(session)
self.app_dirs = get_app_dirs()
self.settings = get_app_settings()
self.t = get_locale_provider().t
def _existing_factory(dependency: Type[CLS_DEP]) -> classmethod:
def cls_method(cls, item_id: T, deps: CLS_DEP = Depends(dependency)):
new_class = cls(deps.session, deps.user, deps.bg_task)
new_class = cls(session=deps.session, user=deps.user, background_tasks=deps.bg_task)
new_class.assert_existing(item_id)
return new_class
@@ -75,7 +77,7 @@ class BaseHttpService(Generic[T, D], ABC):
def _class_method_factory(dependency: Type[CLS_DEP]) -> classmethod:
def cls_method(cls, deps: CLS_DEP = Depends(dependency)):
return cls(deps.session, deps.user, deps.bg_task)
return cls(session=deps.session, user=deps.user, background_tasks=deps.bg_task)
return classmethod(cls_method)

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Generic, TypeVar
@@ -8,6 +10,7 @@ from sqlalchemy.orm import Session
from mealie.core.root_logger import get_logger
from mealie.db.data_access_layer._access_model import AccessModel
from mealie.schema.response import ErrorResponse
C = TypeVar("C", bound=BaseModel)
R = TypeVar("R", bound=BaseModel)
@@ -29,12 +32,23 @@ class CrudHttpMixins(Generic[C, R, U], ABC):
self.item = self.dal.get_one(id)
return self.item
def _create_one(self, data: C, exception_msg="generic-create-error") -> R:
def _create_one(self, data: C, default_msg="generic-create-error", exception_msgs: dict | None = None) -> R:
try:
self.item = self.dal.create(data)
except Exception as ex:
logger.exception(ex)
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": exception_msg, "exception": str(ex)})
msg = default_msg
if exception_msgs:
msg = exception_msgs.get(type(ex), default_msg)
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=ErrorResponse(
message=msg,
exception=str(ex),
).dict(),
)
return self.item

View File

@@ -1,7 +1,9 @@
from mealie.core.config import get_app_dirs, get_app_settings
from mealie.lang import get_locale_provider
class BaseService:
def __init__(self) -> None:
self.app_dirs = get_app_dirs()
self.settings = get_app_settings()
self.t = get_locale_provider()

View File

@@ -0,0 +1 @@
from .ingredient_parser_service import *

View File

@@ -38,7 +38,7 @@ def replace_fraction_unicode(string: str):
continue
if name.startswith("VULGAR FRACTION"):
normalized = unicodedata.normalize("NFKC", c)
numerator, _slash, denominator = normalized.partition("")
numerator, _, denominator = normalized.partition("") # _ = slash
text = f" {numerator}/{denominator}"
return string.replace(c, text).replace(" ", " ")

View File

@@ -0,0 +1,46 @@
import subprocess
import tempfile
from fractions import Fraction
from pathlib import Path
from pydantic import BaseModel, validator
from . import utils
from .pre_processor import pre_process_string
CWD = Path(__file__).parent
MODEL_PATH = CWD / "model.crfmodel"
class CRFIngredient(BaseModel):
input: str = ""
name: str = ""
other: str = ""
qty: str = ""
comment: str = ""
unit: str = ""
@validator("qty", always=True, pre=True)
def validate_qty(qty, values): # sourcery skip: merge-nested-ifs
if qty is None or qty == "":
# Check if other contains a fraction
if values["other"] is not None and values["other"].find("/") != -1:
return float(Fraction(values["other"])).__round__(1)
else:
return 1
return qty
def _exec_crf_test(input_text):
with tempfile.NamedTemporaryFile(mode="w") as input_file:
input_file.write(utils.export_data(input_text))
input_file.flush()
return subprocess.check_output(["crf_test", "--verbose=1", "--model", MODEL_PATH, input_file.name]).decode(
"utf-8"
)
def convert_list_to_crf_model(list_of_ingrdeint_text: list[str]):
crf_output = _exec_crf_test([pre_process_string(x) for x in list_of_ingrdeint_text])
return [CRFIngredient(**ingredient) for ingredient in utils.import_data(crf_output.split("\n"))]

View File

@@ -0,0 +1,55 @@
from abc import ABC, abstractmethod
from fractions import Fraction
from mealie.core.root_logger import get_logger
from mealie.schema.recipe import RecipeIngredient
from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, CreateIngredientUnit
from .crfpp.processor import CRFIngredient, convert_list_to_crf_model
logger = get_logger(__name__)
class ABCIngredientParser(ABC):
"""
Abstract class for ingredient parsers.
"""
@abstractmethod
def parse(self, ingredients: list[str]) -> list[RecipeIngredient]:
...
class CRFPPIngredientParser(ABCIngredientParser):
"""
Class for CRFPP ingredient parsers.
"""
def __init__(self) -> None:
pass
def _crf_to_ingredient(self, crf_model: CRFIngredient) -> RecipeIngredient:
ingredient = None
try:
ingredient = RecipeIngredient(
title="",
note=crf_model.comment,
unit=CreateIngredientUnit(name=crf_model.unit),
food=CreateIngredientFood(name=crf_model.name),
disable_amount=False,
quantity=float(sum(Fraction(s) for s in crf_model.qty.split())),
)
except Exception as e:
# TODO: Capture some sort of state for the user to see that an exception occured
logger.exception(e)
ingredient = RecipeIngredient(
title="",
note=crf_model.input,
)
return ingredient
def parse(self, ingredients: list[str]) -> list[RecipeIngredient]:
crf_models = convert_list_to_crf_model(ingredients)
return [self._crf_to_ingredient(crf_model) for crf_model in crf_models]

View File

@@ -0,0 +1,28 @@
from mealie.schema.recipe import RecipeIngredient
from mealie.services._base_http_service.http_services import UserHttpService
from .ingredient_parser import ABCIngredientParser, CRFPPIngredientParser
class IngredientParserService(UserHttpService):
def __init__(self, parser: ABCIngredientParser = None, *args, **kwargs) -> None:
self.parser: ABCIngredientParser = parser() if parser else CRFPPIngredientParser()
super().__init__(*args, **kwargs)
def populate_item(self) -> None:
"""Satisfy abstract method"""
pass
def parse_ingredient(self, ingredient: str) -> RecipeIngredient:
parsed = self.parser.parse([ingredient])
if parsed:
return parsed[0]
# TODO: Raise Exception
def parse_ingredients(self, ingredients: list[str]) -> list[RecipeIngredient]:
parsed = self.parser.parse(ingredients)
if parsed:
return parsed
# TODO: Raise Exception

View File

@@ -7,6 +7,7 @@ from typing import Union
from zipfile import ZipFile
from fastapi import Depends, HTTPException, UploadFile, status
from sqlalchemy import exc
from mealie.core.dependencies.grouped import PublicDeps, UserDeps
from mealie.core.root_logger import get_logger
@@ -33,6 +34,10 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
event_func = create_recipe_event
@cached_property
def exception_key(self) -> dict:
return {exc.IntegrityError: self.t("recipe.unique-name-error")}
@cached_property
def dal(self) -> RecipeDataAccessModel:
return self.db.recipes
@@ -53,14 +58,13 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
if not self.item.settings.public and not self.user:
raise HTTPException(status.HTTP_403_FORBIDDEN)
# CRUD METHODS
def get_all(self, start=0, limit=None):
items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit)
return [RecipeSummary.construct(**x.__dict__) for x in items]
def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
create_data = recipe_creation_factory(self.user, name=create_data.name, additional_attrs=create_data.dict())
self._create_one(create_data, "RECIPE_ALREAD_EXISTS")
self._create_one(create_data, self.t("generic.server-error"), self.exception_key)
self._create_event(
"Recipe Created",
f"'{self.item.name}' by {self.user.username} \n {self.settings.BASE_URL}/recipe/{self.item.slug}",

View File

@@ -1,85 +0,0 @@
import subprocess
import tempfile
from fractions import Fraction
from pathlib import Path
from typing import Optional
from pydantic import BaseModel, validator
from mealie.core.config import get_app_settings
from mealie.schema.recipe import RecipeIngredient
from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, CreateIngredientUnit
from . import utils
from .pre_processor import pre_process_string
CWD = Path(__file__).parent
MODEL_PATH = CWD / "model.crfmodel"
settings = get_app_settings()
INGREDIENT_TEXT = [
"2 tablespoons honey",
"1/2 cup flour",
"Black pepper, to taste",
"2 cups of garlic finely chopped",
"2 liters whole milk",
]
class CRFIngredient(BaseModel):
input: Optional[str] = ""
name: Optional[str] = ""
other: Optional[str] = ""
qty: Optional[str] = ""
comment: Optional[str] = ""
unit: Optional[str] = ""
@validator("qty", always=True, pre=True)
def validate_qty(qty, values): # sourcery skip: merge-nested-ifs
if qty is None or qty == "":
# Check if other contains a fraction
if values["other"] is not None and values["other"].find("/") != -1:
return float(Fraction(values["other"])).__round__(1)
else:
return 1
return qty
def _exec_crf_test(input_text):
with tempfile.NamedTemporaryFile(mode="w") as input_file:
input_file.write(utils.export_data(input_text))
input_file.flush()
return subprocess.check_output(["crf_test", "--verbose=1", "--model", MODEL_PATH, input_file.name]).decode(
"utf-8"
)
def convert_list_to_crf_model(list_of_ingrdeint_text: list[str]):
crf_output = _exec_crf_test([pre_process_string(x) for x in list_of_ingrdeint_text])
crf_models = [CRFIngredient(**ingredient) for ingredient in utils.import_data(crf_output.split("\n"))]
for model in crf_models:
print(model)
return crf_models
def convert_crf_models_to_ingredients(crf_models: list[CRFIngredient]):
return [
RecipeIngredient(
title="",
note=crf_model.comment,
unit=CreateIngredientUnit(name=crf_model.unit),
food=CreateIngredientFood(name=crf_model.name),
disable_amount=settings.RECIPE_DISABLE_AMOUNT,
quantity=float(sum(Fraction(s) for s in crf_model.qty.split())),
)
for crf_model in crf_models
]
if __name__ == "__main__":
crf_models = convert_list_to_crf_model(INGREDIENT_TEXT)
ingredients = convert_crf_models_to_ingredients(crf_models)