feat: Add brute strategy to ingredient processor (#744)

* fix UI column width

* words

* update parser to support diff strats

* add new model url

* make button more visible

* fix nutrition error

* feat(backend):  add 'brute' strategy for parsing ingredients

* satisfy linter

* update UI for creation page

* feat(backend):  log 422 errors in detail when not in PRODUCTION

* add strategy selector

Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-10-16 16:06:13 -08:00 committed by GitHub
parent 60908e5a88
commit 3b920babe3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 961 additions and 131 deletions

View File

@ -106,7 +106,7 @@ COPY --from=builder-base $POETRY_HOME $POETRY_HOME
COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH
# copy CRF++ Binary from crfpp # copy CRF++ Binary from crfpp
ENV CRF_MODEL_URL=https://github.com/hay-kot/mealie-nlp-model/releases/download/v1.0.0/model.crfmodel ENV CRF_MODEL_URL=https://github.com/mealie-recipes/nlp-model/releases/download/v1.0.0/model.crfmodel
ENV LD_LIBRARY_PATH=/usr/local/lib ENV LD_LIBRARY_PATH=/usr/local/lib
COPY --from=crfpp /usr/local/lib/ /usr/local/lib COPY --from=crfpp /usr/local/lib/ /usr/local/lib

View File

@ -22,6 +22,43 @@ const routes = {
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`, recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`,
}; };
export type Parser = "nlp" | "brute";
export interface Confidence {
average?: number;
comment?: number;
name?: number;
unit?: number;
quantity?: number;
food?: number;
}
export interface Unit {
name: string;
description: string;
fraction: boolean;
abbreviation: string;
}
export interface Food {
name: string;
description: string;
}
export interface Ingredient {
title: string;
note: string;
unit: Unit;
food: Food;
disableAmount: boolean;
quantity: number;
}
export interface ParsedIngredient {
confidence: Confidence;
ingredient: Ingredient;
}
export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> { export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
baseRoute: string = routes.recipesBase; baseRoute: string = routes.recipesBase;
itemRoute = routes.recipesRecipeSlug; itemRoute = routes.recipesRecipeSlug;
@ -84,11 +121,13 @@ export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
return await this.requests.delete(routes.recipesSlugCommentsId(slug, id)); return await this.requests.delete(routes.recipesSlugCommentsId(slug, id));
} }
async parseIngredients(ingredients: Array<string>) { async parseIngredients(parser: Parser, ingredients: Array<string>) {
return await this.requests.post(routes.recipesParseIngredients, { ingredients }); parser = parser || "nlp";
return await this.requests.post<ParsedIngredient[]>(routes.recipesParseIngredients, { parser, ingredients });
} }
async parseIngredient(ingredient: string) { async parseIngredient(parser: Parser, ingredient: string) {
return await this.requests.post(routes.recipesParseIngredient, { ingredient }); parser = parser || "nlp";
return await this.requests.post<ParsedIngredient>(routes.recipesParseIngredient, { parser, ingredient });
} }
} }

View File

@ -0,0 +1,82 @@
<template>
<v-menu offset-y>
<template #activator="{ on, attrs }">
<v-btn color="primary" v-bind="attrs" :class="btnClass" v-on="on">
<v-icon v-if="activeObj.icon" left>
{{ activeObj.icon }}
</v-icon>
{{ activeObj.text }}
<v-icon right>
{{ $globals.icons.chevronDown }}
</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item-group v-model="itemGroup">
<v-list-item v-for="(item, index) in items" :key="index" @click="setValue(item)">
<v-list-item-icon v-if="item.icon">
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ item.text }}</v-list-item-title>
</v-list-item>
</v-list-item-group>
</v-list>
</v-menu>
</template>
<script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
const INPUT_EVENT = "input";
export default defineComponent({
props: {
items: {
type: Array,
required: true,
},
value: {
type: String,
required: false,
default: "",
},
btnClass: {
type: String,
required: false,
default: "",
},
},
setup(props, context) {
const activeObj = ref({
text: "DEFAULT",
value: "",
});
let startIndex = 0;
props.items.forEach((item, index) => {
// @ts-ignore
if (item.value === props.value) {
startIndex = index;
// @ts-ignore
activeObj.value = item;
}
});
const itemGroup = ref(startIndex);
function setValue(v: any) {
context.emit(INPUT_EVENT, v.value);
activeObj.value = v;
}
return {
activeObj,
itemGroup,
setValue,
};
},
});
</script>

View File

@ -1,18 +1,28 @@
<template> <template>
<v-container> <v-container class="pa-0">
<v-container> <v-container>
<BaseCardSectionTitle title="Ingredients Natural Language Processor"> <BaseCardSectionTitle title="Ingredients Natural Language Processor">
Mealie uses conditional random Conditional Random Fields (CRFs) for parsing and processing ingredients. The Mealie uses Conditional Random Fields (CRFs) for parsing and processing ingredients. The model used for
model used for ingredients is based off a data set of over 100,000 ingredients from a dataset compiled by the ingredients is based off a data set of over 100,000 ingredients from a dataset compiled by the New York Times.
New York Times. Note that as the model is trained in English only, you may have varied results when using the Note that as the model is trained in English only, you may have varied results when using the model in other
model in other languages. This page is a playground for testing the model. languages. This page is a playground for testing the model.
<p class="pt-3"> <p class="pt-3">
It's not perfect, but it yields great results in general and is a good starting point for manually parsing It's not perfect, but it yields great results in general and is a good starting point for manually parsing
ingredients into individual fields. ingredients into individual fields. Alternatively, you can also use the "Brute" processor that uses a pattern
matching technique to identify ingredients.
</p> </p>
</BaseCardSectionTitle> </BaseCardSectionTitle>
<div class="d-flex align-center justify-center justify-md-start flex-wrap">
<v-btn-toggle v-model="parser" dense mandatory @change="processIngredient">
<v-btn value="nlp"> NLP </v-btn>
<v-btn value="brute"> Brute </v-btn>
</v-btn-toggle>
<v-checkbox v-model="showConfidence" class="ml-5" label="Show individual confidence"></v-checkbox>
</div>
<v-card flat> <v-card flat>
<v-card-text> <v-card-text>
<v-text-field v-model="ingredient" label="Ingredient Text"> </v-text-field> <v-text-field v-model="ingredient" label="Ingredient Text"> </v-text-field>
@ -26,22 +36,29 @@
</v-card> </v-card>
</v-container> </v-container>
<v-container v-if="results"> <v-container v-if="results">
<v-row class="d-flex"> <div v-if="parser !== 'brute' && getConfidence('average')" class="d-flex">
<v-chip dark :color="getColor('average')" class="mx-auto mb-2">
{{ getConfidence("average") }} Confident
</v-chip>
</div>
<div class="d-flex justify-center flex-wrap" style="gap: 1.5rem">
<template v-for="(prop, index) in properties"> <template v-for="(prop, index) in properties">
<v-col v-if="prop.value" :key="index" xs="12" sm="6" lg="3"> <div v-if="prop.value" :key="index" class="flex-grow-1">
<v-card> <v-card min-width="200px">
<v-card-title> {{ prop.value }} </v-card-title> <v-card-title> {{ prop.value }} </v-card-title>
<v-card-text> <v-card-text>
{{ prop.subtitle }} {{ prop.subtitle }}
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> <v-chip v-if="prop.confidence && showConfidence" dark :color="prop.color" class="mt-2">
{{ prop.confidence }} Confident
</v-chip>
</div>
</template> </template>
</v-row> </div>
</v-container> </v-container>
<v-container class="narrow-container"> <v-container class="narrow-container">
<v-card-title> Try an example </v-card-title> <v-card-title> Try an example </v-card-title>
<v-card v-for="(text, idx) in tryText" :key="idx" class="my-2" hover @click="processTryText(text)"> <v-card v-for="(text, idx) in tryText" :key="idx" class="my-2" hover @click="processTryText(text)">
<v-card-text> {{ text }} </v-card-text> <v-card-text> {{ text }} </v-card-text>
</v-card> </v-card>
@ -50,7 +67,8 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, toRefs } from "@nuxtjs/composition-api"; import { defineComponent, reactive, ref, toRefs } from "@nuxtjs/composition-api";
import { Confidence, Parser } from "~/api/class-interfaces/recipes";
import { useApiSingleton } from "~/composables/use-api"; import { useApiSingleton } from "~/composables/use-api";
export default defineComponent({ export default defineComponent({
@ -62,8 +80,41 @@ export default defineComponent({
loading: false, loading: false,
ingredient: "", ingredient: "",
results: false, results: false,
parser: "nlp" as Parser,
}); });
const confidence = ref<Confidence>({});
function getColor(attribute: string) {
const percentage = getConfidence(attribute);
// @ts-ignore
const p_as_num = parseFloat(percentage?.replace("%", ""));
// Set color based off range
if (p_as_num > 75) {
return "success";
} else if (p_as_num > 60) {
return "warning";
} else {
return "error";
}
}
function getConfidence(attribute: string) {
attribute = attribute.toLowerCase();
if (!confidence.value) {
return;
}
// @ts-ignore
const property: number = confidence.value[attribute];
if (property) {
return `${(property * 100).toFixed(0)}%`;
}
return null;
}
const tryText = [ const tryText = [
"2 tbsp minced cilantro, leaves and stems", "2 tbsp minced cilantro, leaves and stems",
"1 large yellow onion, coarsely chopped", "1 large yellow onion, coarsely chopped",
@ -78,23 +129,39 @@ export default defineComponent({
} }
async function processIngredient() { async function processIngredient() {
if (state.ingredient === "") {
return;
}
state.loading = true; state.loading = true;
const { data } = await api.recipes.parseIngredient(state.ingredient);
const { data } = await api.recipes.parseIngredient(state.parser, state.ingredient);
if (data) { if (data) {
state.results = true; state.results = true;
confidence.value = data.confidence;
// TODO: Remove ts-ignore // TODO: Remove ts-ignore
// ts-ignore because data will likely change significantly once I figure out how to return results // ts-ignore because data will likely change significantly once I figure out how to return results
// for the parser. For now we'll leave it like this // for the parser. For now we'll leave it like this
properties.comment.value = data.ingredient.note || "";
properties.quantity.value = data.ingredient.quantity || "";
properties.unit.value = data.ingredient.unit.name || "";
properties.food.value = data.ingredient.food.name || "";
for (const property in properties) {
const color = getColor(property);
const confidence = getConfidence(property);
if (color) {
// @ts-ignore // @ts-ignore
properties.comments.value = data.ingredient.note || null; properties[property].color = color;
}
if (confidence) {
// @ts-ignore // @ts-ignore
properties.quantity.value = data.ingredient.quantity || null; properties[property].confidence = confidence;
// @ts-ignore }
properties.unit.value = data.ingredient.unit.name || null; }
// @ts-ignore
properties.food.value = data.ingredient.food.name || null;
} }
state.loading = false; state.loading = false;
} }
@ -102,23 +169,37 @@ export default defineComponent({
const properties = reactive({ const properties = reactive({
quantity: { quantity: {
subtitle: "Quantity", subtitle: "Quantity",
value: "Value", value: "" as any,
color: null,
confidence: null,
}, },
unit: { unit: {
subtitle: "Unit", subtitle: "Unit",
value: "Value", value: "",
color: null,
confidence: null,
}, },
food: { food: {
subtitle: "Food", subtitle: "Food",
value: "Value", value: "",
color: null,
confidence: null,
}, },
comments: { comment: {
subtitle: "Comments", subtitle: "Comment",
value: "Value", value: "",
color: null,
confidence: null,
}, },
}); });
const showConfidence = ref(false);
return { return {
showConfidence,
getColor,
confidence,
getConfidence,
...toRefs(state), ...toRefs(state),
tryText, tryText,
properties, properties,

View File

@ -1,23 +1,69 @@
<template> <template>
<v-container v-if="recipe"> <v-container v-if="recipe">
<v-container> <v-container>
<BaseCardSectionTitle title="Ingredients Processor"> </BaseCardSectionTitle> <BaseCardSectionTitle title="Ingredients Processor">
To use the ingredient parser, click the "Parse All" button and the process will start. When the processed
ingredients are available, you can look through the items and verify that they were parsed correctly. The models
confidence score is displayed on the right of the title item. This is an average of all scores and may not be
wholey accurate.
<div class="mt-6">
Alerts will be displayed if a matching foods or unit is found but does not exists in the database.
</div>
<v-divider class="my-4"> </v-divider>
<div class="mb-n4">
Select Parser
<BaseOverflowButton
v-model="parser"
btn-class="mx-2"
:items="[
{
text: 'Natural Language Processor ',
value: 'nlp',
},
{
text: 'Brute Parser',
value: 'brute',
},
]"
/>
</div>
</BaseCardSectionTitle>
<v-card-actions class="justify-end"> <v-card-actions class="justify-end">
<BaseButton color="info"> <BaseButton color="info" @click="fetchParsed">
<template #icon> {{ $globals.icons.foods }}</template> <template #icon> {{ $globals.icons.foods }}</template>
Parse All Parse All
</BaseButton> </BaseButton>
<BaseButton save> Save All </BaseButton> <BaseButton save @click="saveAll"> Save All </BaseButton>
</v-card-actions> </v-card-actions>
</v-card>
<v-expansion-panels v-model="panels" multiple> <v-expansion-panels v-model="panels" multiple>
<v-expansion-panel v-for="(ing, index) in ingredients" :key="index"> <v-expansion-panel v-for="(ing, index) in parsedIng" :key="index">
<v-expansion-panel-header class="my-0 py-0"> <v-expansion-panel-header class="my-0 py-0" disable-icon-rotate>
{{ recipe.recipeIngredient[index].note }} {{ ing.input }}
<template #actions>
<v-icon left :color="isError(ing) ? 'error' : 'success'">
{{ isError(ing) ? $globals.icons.alert : $globals.icons.check }}
</v-icon>
<div class="my-auto" :color="isError(ing) ? 'error-text' : 'success-text'">
{{ asPercentage(ing.confidence.average) }}
</div>
</template>
</v-expansion-panel-header> </v-expansion-panel-header>
<v-expansion-panel-content class="pb-0 mb-0"> <v-expansion-panel-content class="pb-0 mb-0">
<RecipeIngredientEditor v-model="ingredients[index]" /> <RecipeIngredientEditor v-model="parsedIng[index].ingredient" />
<v-card-actions>
<v-spacer></v-spacer>
<BaseButton
v-if="errors[index].foodError && errors[index].foodErrorMessage !== ''"
color="warning"
small
@click="createFood(ing.ingredient.food, index)"
>
{{ errors[index].foodErrorMessage }}
</BaseButton>
</v-card-actions>
</v-expansion-panel-content> </v-expansion-panel-content>
</v-expansion-panel> </v-expansion-panel>
</v-expansion-panels> </v-expansion-panels>
@ -26,19 +72,32 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, ref, toRefs, useRoute, watch } from "@nuxtjs/composition-api"; import { defineComponent, ref, useRoute, useRouter } from "@nuxtjs/composition-api";
import { Food, ParsedIngredient, Parser } from "~/api/class-interfaces/recipes";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue"; import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import { useApiSingleton } from "~/composables/use-api"; import { useApiSingleton } from "~/composables/use-api";
import { useRecipeContext } from "~/composables/use-recipe-context"; import { useRecipeContext } from "~/composables/use-recipe-context";
import { useFoods } from "~/composables/use-recipe-foods";
import { useUnits } from "~/composables/use-recipe-units";
import { RecipeIngredientUnit } from "~/types/api-types/recipe";
interface Error {
ingredientIndex: number;
unitError: Boolean;
unitErrorMessage: string;
foodError: Boolean;
foodErrorMessage: string;
}
export default defineComponent({ export default defineComponent({
components: { components: {
RecipeIngredientEditor, RecipeIngredientEditor,
}, },
setup() { setup() {
const state = reactive({ const panels = ref<number[]>([]);
panels: null,
});
const route = useRoute(); const route = useRoute();
const router = useRouter();
const slug = route.value.params.slug; const slug = route.value.params.slug;
const api = useApiSingleton(); const api = useApiSingleton();
@ -48,14 +107,150 @@ export default defineComponent({
const ingredients = ref<any[]>([]); const ingredients = ref<any[]>([]);
watch(recipe, () => { // =========================================================
const copy = recipe?.value?.recipeIngredient || []; // Parser Logic
ingredients.value = [...copy];
}); const parser = ref<Parser>("nlp");
const parsedIng = ref<any[]>([]);
async function fetchParsed() {
if (!recipe.value) {
return;
}
const raw = recipe.value.recipeIngredient.map((ing) => ing.note);
const { response, data } = await api.recipes.parseIngredients(parser.value, raw);
console.log({ response });
if (data) {
parsedIng.value = data;
console.log(data);
// @ts-ignore
errors.value = data.map((ing, index: number) => {
const unitError = !checkForUnit(ing.ingredient.unit);
const foodError = !checkForFood(ing.ingredient.food);
let unitErrorMessage = "";
let foodErrorMessage = "";
if (unitError || foodError) {
if (unitError) {
if (ing?.ingredient?.unit?.name) {
unitErrorMessage = `Create missing unit '${ing?.ingredient?.unit?.name || "No unit"}'`;
}
}
if (foodError) {
if (ing?.ingredient?.food?.name) {
foodErrorMessage = `Create missing food '${ing.ingredient.food.name || "No food"}'?`;
}
panels.value.push(index);
}
}
return { return {
...toRefs(state), ingredientIndex: index,
api, unitError,
unitErrorMessage,
foodError,
foodErrorMessage,
};
});
}
}
function isError(ing: ParsedIngredient) {
if (!ing?.confidence?.average) {
return true;
}
return !(ing.confidence.average >= 0.75);
}
function asPercentage(num: number) {
return Math.round(num * 100).toFixed(2) + "%";
}
// =========================================================
// Food and Ingredient Logic
const { foods, workingFoodData, actions } = useFoods();
const { units } = useUnits();
const errors = ref<Error[]>([]);
function checkForUnit(unit: RecipeIngredientUnit) {
if (units.value && unit?.name) {
return units.value.some((u) => u.name === unit.name);
}
return false;
}
function checkForFood(food: Food) {
if (foods.value && food?.name) {
return foods.value.some((f) => f.name === food.name);
}
return false;
}
async function createFood(food: Food, index: number) {
workingFoodData.name = food.name;
await actions.createOne();
errors.value[index].foodError = false;
}
// =========================================================
// Save All Loginc
async function saveAll() {
let ingredients = parsedIng.value.map((ing) => {
return {
...ing.ingredient,
};
});
console.log(ingredients);
ingredients = ingredients.map((ing) => {
if (!foods.value || !units.value) {
return ing;
}
// Get food from foods
const food = foods.value.find((f) => f.name === ing.food.name);
ing.food = food || null;
// Get unit from units
const unit = units.value.find((u) => u.name === ing.unit.name);
ing.unit = unit || null;
console.log(ing);
return ing;
});
if (!recipe.value) {
return;
}
recipe.value.recipeIngredient = ingredients;
const { response } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
if (response?.status === 200) {
router.push("/recipe/" + recipe.value.slug);
}
}
return {
parser,
saveAll,
createFood,
errors,
actions,
workingFoodData,
isError,
panels,
asPercentage,
fetchParsed,
parsedIng,
recipe, recipe,
loading, loading,
ingredients, ingredients,
@ -69,5 +264,3 @@ export default defineComponent({
}); });
</script> </script>
<style scoped>
</style>

View File

@ -7,11 +7,8 @@
<template #title> Recipe Creation </template> <template #title> Recipe Creation </template>
Select one of the various ways to create a recipe Select one of the various ways to create a recipe
</BasePageTitle> </BasePageTitle>
<v-tabs v-model="tab"> <BaseOverflowButton v-model="tab" rounded class="mx-2" outlined :items="tabs"> </BaseOverflowButton>
<v-tab href="#url">From URL</v-tab>
<v-tab href="#new">Create</v-tab>
<v-tab href="#zip">Import Zip</v-tab>
</v-tabs>
<section> <section>
<v-tabs-items v-model="tab" class="mt-10"> <v-tabs-items v-model="tab" class="mt-10">
<v-tab-item value="url" eager> <v-tab-item value="url" eager>
@ -127,7 +124,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api"; import { defineComponent, reactive, toRefs, ref, useRouter, useContext } from "@nuxtjs/composition-api";
import { useApiSingleton } from "~/composables/use-api"; import { useApiSingleton } from "~/composables/use-api";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
export default defineComponent({ export default defineComponent({
@ -137,6 +134,27 @@ export default defineComponent({
loading: false, loading: false,
}); });
// @ts-ignore - $globals not found in type definition
const { $globals } = useContext();
const tabs = [
{
icon: $globals.icons.edit,
text: "Create Recipe",
value: "new",
},
{
icon: $globals.icons.link,
text: "Import with URL",
value: "url",
},
{
icon: $globals.icons.zip,
text: "Import with .zip",
value: "zip",
},
];
const api = useApiSingleton(); const api = useApiSingleton();
const router = useRouter(); const router = useRouter();
@ -203,6 +221,7 @@ export default defineComponent({
} }
return { return {
tabs,
domCreateByName, domCreateByName,
domUrlForm, domUrlForm,
newRecipeName, newRecipeName,

View File

@ -10,11 +10,11 @@
<section> <section>
<ToggleState tag="article"> <ToggleState tag="article">
<template #activator="{ toggle, state }"> <template #activator="{ toggle, state }">
<v-btn v-if="!state" text color="info" class="mt-2 mb-n3" @click="toggle"> <v-btn v-if="!state" color="info" class="mt-2 mb-n3" @click="toggle">
<v-icon left>{{ $globals.icons.lock }}</v-icon> <v-icon left>{{ $globals.icons.lock }}</v-icon>
{{ $t("settings.change-password") }} {{ $t("settings.change-password") }}
</v-btn> </v-btn>
<v-btn v-else text color="info" class="mt-2 mb-n3" @click="toggle"> <v-btn v-else color="info" class="mt-2 mb-n3" @click="toggle">
<v-icon left>{{ $globals.icons.user }}</v-icon> <v-icon left>{{ $globals.icons.user }}</v-icon>
{{ $t("settings.profile") }} {{ $t("settings.profile") }}
</v-btn> </v-btn>

View File

@ -30,6 +30,7 @@ import {
mdiDotsVertical, mdiDotsVertical,
mdiPrinter, mdiPrinter,
mdiShareVariant, mdiShareVariant,
mdiChevronDown,
mdiHeart, mdiHeart,
mdiHeartOutline, mdiHeartOutline,
mdiDotsHorizontal, mdiDotsHorizontal,
@ -210,4 +211,5 @@ export const icons = {
forward: mdiArrowRightBoldOutline, forward: mdiArrowRightBoldOutline,
back: mdiArrowLeftBoldOutline, back: mdiArrowLeftBoldOutline,
slotMachine: mdiSlotMachine, slotMachine: mdiSlotMachine,
chevronDown: mdiChevronDown,
}; };

View File

@ -7,6 +7,7 @@ from mealie.core.root_logger import get_logger
from mealie.core.settings.static import APP_VERSION from mealie.core.settings.static import APP_VERSION
from mealie.routes import backup_routes, migration_routes, router, utility_routes from mealie.routes import backup_routes, migration_routes, router, utility_routes
from mealie.routes.about import about_router from mealie.routes.about import about_router
from mealie.routes.handlers import register_debug_handler
from mealie.routes.media import media_router from mealie.routes.media import media_router
from mealie.routes.site_settings import settings_router from mealie.routes.site_settings import settings_router
from mealie.services.events import create_general_event from mealie.services.events import create_general_event
@ -25,6 +26,8 @@ app = FastAPI(
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
register_debug_handler(app)
def start_scheduler(): def start_scheduler():
SchedulerService.start() SchedulerService.start()

View File

@ -117,7 +117,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
tools: list[str] = None, tools: list[str] = None,
**_ **_
) -> None: ) -> None:
self.nutrition = Nutrition(**nutrition) if self.nutrition else Nutrition() self.nutrition = Nutrition(**nutrition) if nutrition else Nutrition()
self.tools = [Tool(tool=x) for x in tools] if tools else [] self.tools = [Tool(tool=x) for x in tools] if tools else []
self.recipe_ingredient = [RecipeIngredient(**ingr, session=session) for ingr in recipe_ingredient] self.recipe_ingredient = [RecipeIngredient(**ingr, session=session) for ingr in recipe_ingredient]
self.assets = [RecipeAsset(**a) for a in assets] self.assets = [RecipeAsset(**a) for a in assets]

33
mealie/routes/handlers.py Normal file
View File

@ -0,0 +1,33 @@
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from mealie.core.config import get_app_settings
from mealie.core.root_logger import get_logger
logger = get_logger(__name__)
def log_wrapper(request: Request, e):
logger.error("Start 422 Error".center(60, "-"))
logger.error(f"{request.method} {request.url}")
logger.error(f"error is {e}")
logger.error("End 422 Error".center(60, "-"))
def register_debug_handler(app: FastAPI):
settings = get_app_settings()
if settings.PRODUCTION:
return
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
exc_str = f"{exc}".replace("\n", " ").replace(" ", " ")
log_wrapper(request, exc)
content = {"status_code": status.HTTP_422_UNPROCESSABLE_ENTITY, "message": exc_str, "data": None}
return JSONResponse(content=content, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
return validation_exception_handler

View File

@ -1,31 +1,25 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel
from mealie.schema.recipe import RecipeIngredient from mealie.schema.recipe import ParsedIngredient
from mealie.schema.recipe.recipe_ingredient import IngredientRequest, IngredientsRequest
from mealie.services.parser_services import IngredientParserService from mealie.services.parser_services import IngredientParserService
public_router = APIRouter(prefix="/parser") public_router = APIRouter(prefix="/parser")
class IngredientsRequest(BaseModel): @public_router.post("/ingredients", response_model=list[ParsedIngredient])
ingredients: list[str]
class IngredientRequest(BaseModel):
ingredient: str
@public_router.post("/ingredients", response_model=list[RecipeIngredient])
def parse_ingredients( def parse_ingredients(
ingredients: IngredientsRequest, ingredients: IngredientsRequest,
p_service: IngredientParserService = Depends(IngredientParserService.private), p_service: IngredientParserService = Depends(IngredientParserService.private),
): ):
return {"ingredients": p_service.parse_ingredients(ingredients.ingredients)} p_service.set_parser(parser=ingredients.parser)
return p_service.parse_ingredients(ingredients.ingredients)
@public_router.post("/ingredient") @public_router.post("/ingredient", response_model=ParsedIngredient)
def parse_ingredient( def parse_ingredient(
ingredient: IngredientRequest, ingredient: IngredientRequest,
p_service: IngredientParserService = Depends(IngredientParserService.private), p_service: IngredientParserService = Depends(IngredientParserService.private),
): ):
return {"ingredient": p_service.parse_ingredient(ingredient.ingredient)} p_service.set_parser(parser=ingredient.parser)
return p_service.parse_ingredient(ingredient.ingredient)

View File

@ -1,3 +1,4 @@
import enum
from typing import Optional, Union from typing import Optional, Union
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
@ -30,10 +31,40 @@ class IngredientUnit(CreateIngredientUnit):
class RecipeIngredient(CamelModel): class RecipeIngredient(CamelModel):
title: Optional[str] title: Optional[str]
note: Optional[str] note: Optional[str]
unit: Optional[Union[CreateIngredientUnit, IngredientUnit]] unit: Optional[Union[IngredientUnit, CreateIngredientUnit]]
food: Optional[Union[CreateIngredientFood, IngredientFood]] food: Optional[Union[IngredientFood, CreateIngredientFood]]
disable_amount: bool = True disable_amount: bool = True
quantity: float = 1 quantity: float = 1
class Config: class Config:
orm_mode = True orm_mode = True
class IngredientConfidence(CamelModel):
average: float = None
comment: float = None
name: float = None
unit: float = None
quantity: float = None
food: float = None
class ParsedIngredient(CamelModel):
input: Optional[str]
confidence: IngredientConfidence = IngredientConfidence()
ingredient: RecipeIngredient
class RegisteredParser(str, enum.Enum):
nlp = "nlp"
brute = "brute"
class IngredientsRequest(CamelModel):
parser: RegisteredParser = RegisteredParser.nlp
ingredients: list[str]
class IngredientRequest(CamelModel):
parser: RegisteredParser = RegisteredParser.nlp
ingredient: str

View File

@ -1 +1,2 @@
from .ingredient_parser import *
from .ingredient_parser_service import * from .ingredient_parser_service import *

View File

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

View File

@ -0,0 +1,23 @@
import re
compiled_match = re.compile(r"(.){1,6}\s\((.[^\(\)])+\)\s")
compiled_search = re.compile(r"\((.[^\(])+\)")
def move_parens_to_end(ing_str) -> str:
"""
Moves all parentheses in the string to the end of the string using Regex.
If no parentheses are found, the string is returned unchanged.
"""
if re.match(compiled_match, ing_str):
match = re.search(compiled_search, ing_str)
start = match.start()
end = match.end()
ing_str = ing_str[:start] + ing_str[end:] + " " + ing_str[start:end]
return ing_str
def check_char(char, *eql) -> bool:
"""Helper method to check if a charaters matches any of the additional provided arguments"""
return any(char == eql_char for eql_char in eql)

View File

@ -0,0 +1 @@
from .process import parse

View File

@ -0,0 +1,204 @@
import string
import unicodedata
from typing import Tuple
from pydantic import BaseModel
from .._helpers import check_char, move_parens_to_end
class BruteParsedIngredient(BaseModel):
food: str = ""
note: str = ""
amount: float = ""
unit: str = ""
class Config:
anystr_strip_whitespace = True
def parse_fraction(x):
if len(x) == 1 and "fraction" in unicodedata.decomposition(x):
frac_split = unicodedata.decomposition(x[-1:]).split()
return float((frac_split[1]).replace("003", "")) / float((frac_split[3]).replace("003", ""))
else:
frac_split = x.split("/")
if len(frac_split) != 2:
raise ValueError
try:
return int(frac_split[0]) / int(frac_split[1])
except ZeroDivisionError:
raise ValueError
def parse_amount(ing_str) -> Tuple[float, str, str]:
def keep_looping(ing_str, end) -> bool:
"""
Checks if:
1. the end of the string is reached
2. or if the next character is a digit
3. or if the next character looks like an number (e.g. 1/2, 1.3, 1,500)
"""
if end >= len(ing_str):
return False
if ing_str[end] in string.digits:
return True
if check_char(ing_str[end], ".", ",", "/") and end + 1 < len(ing_str) and ing_str[end + 1] in string.digits:
return True
amount = 0
unit = ""
note = ""
did_check_frac = False
end = 0
while keep_looping(ing_str, end):
end += 1
if end > 0:
if "/" in ing_str[:end]:
amount = parse_fraction(ing_str[:end])
else:
amount = float(ing_str[:end].replace(",", "."))
else:
amount = parse_fraction(ing_str[0])
end += 1
did_check_frac = True
if end < len(ing_str):
if did_check_frac:
unit = ing_str[end:]
else:
try:
amount += parse_fraction(ing_str[end])
unit_end = end + 1
unit = ing_str[unit_end:]
except ValueError:
unit = ing_str[end:]
# i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
if unit.startswith("(") or unit.startswith("-"):
unit = ""
note = ing_str
return amount, unit, note
def parse_ingredient_with_comma(tokens) -> Tuple[str, str]:
ingredient = ""
note = ""
start = 0
# search for first occurrence of an argument ending in a comma
while start < len(tokens) and not tokens[start].endswith(","):
start += 1
if start == len(tokens):
# no token ending in a comma found -> use everything as ingredient
ingredient = " ".join(tokens)
else:
ingredient = " ".join(tokens[: start + 1])[:-1]
note_end = start + 1
note = " ".join(tokens[note_end:])
return ingredient, note
def parse_ingredient(tokens) -> Tuple[str, str]:
ingredient = ""
note = ""
if tokens[-1].endswith(")"):
# Check if the matching opening bracket is in the same token
if (not tokens[-1].startswith("(")) and ("(" in tokens[-1]):
return parse_ingredient_with_comma(tokens)
# last argument ends with closing bracket -> look for opening bracket
start = len(tokens) - 1
while not tokens[start].startswith("(") and start != 0:
start -= 1
if start == 0:
# the whole list is wrapped in brackets -> assume it is an error (e.g. assumed first argument was the unit) # noqa: E501
raise ValueError
elif start < 0:
# no opening bracket anywhere -> just ignore the last bracket
ingredient, note = parse_ingredient_with_comma(tokens)
else:
# opening bracket found -> split in ingredient and note, remove brackets from note # noqa: E501
note = " ".join(tokens[start:])[1:-1]
ingredient = " ".join(tokens[:start])
else:
ingredient, note = parse_ingredient_with_comma(tokens)
return ingredient, note
def parse(ing_str) -> BruteParsedIngredient:
amount = 0
unit = ""
ingredient = ""
note = ""
unit_note = ""
ing_str = move_parens_to_end(ing_str)
tokens = ing_str.split()
# Early return if the ingrdient is a single token and therefore has no other properties
if len(tokens) == 1:
ingredient = tokens[0]
# TODO Refactor to expect BFP to be returned instead of Tuple
return BruteParsedIngredient(food=ingredient, note=note, amount=amount, unit=unit)
try:
# try to parse first argument as amount
amount, unit, unit_note = parse_amount(tokens[0])
# only try to parse second argument as amount if there are at least
# three arguments if it already has a unit there can't be
# a fraction for the amount
if len(tokens) > 2:
try:
if unit != "":
# a unit is already found, no need to try the second argument for a fraction
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except # noqa: E501
raise ValueError
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½'
amount += parse_fraction(tokens[1])
# assume that units can't end with a comma
if len(tokens) > 3 and not tokens[2].endswith(","):
# try to use third argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
try:
ingredient, note = parse_ingredient(tokens[3:])
unit = tokens[2]
except ValueError:
ingredient, note = parse_ingredient(tokens[2:])
else:
ingredient, note = parse_ingredient(tokens[2:])
except ValueError:
# assume that units can't end with a comma
if not tokens[1].endswith(","):
# try to use second argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
try:
ingredient, note = parse_ingredient(tokens[2:])
if unit == "":
unit = tokens[1]
else:
note = tokens[1]
except ValueError:
ingredient, note = parse_ingredient(tokens[1:])
else:
ingredient, note = parse_ingredient(tokens[1:])
else:
# only two arguments, first one is the amount
# which means this is the ingredient
ingredient = tokens[1]
except ValueError:
try:
# can't parse first argument as amount
# -> no unit -> parse everything as ingredient
ingredient, note = parse_ingredient(tokens)
except ValueError:
ingredient = " ".join(tokens[1:])
if unit_note not in note:
note += " " + unit_note
return BruteParsedIngredient(food=ingredient, note=note, amount=amount, unit=unit)

View File

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

View File

@ -10,15 +10,17 @@ replace_abbreviations = {
"oz": " ounce ", "oz": " ounce ",
"pint": " pint ", "pint": " pint ",
"qt": " quart ", "qt": " quart ",
"tbs ": "tablespoon ",
"tbsp": " tablespoon ", "tbsp": " tablespoon ",
"tbs": " tablespoon ", # Order Matters!, 'tsb' must come after 'tbsp' incase of duplicate matches
"tsp": " teaspoon ", "tsp": " teaspoon ",
} }
def replace_common_abbreviations(string: str) -> str: def replace_common_abbreviations(string: str) -> str:
for k, v in replace_abbreviations.items(): for k, v in replace_abbreviations.items():
string = string.replace(k, v) regex = rf"(?<=\d)\s?({k}s?)"
string = re.sub(regex, v, string)
return string return string
@ -81,17 +83,3 @@ def pre_process_string(string: str) -> str:
string = wrap_or_clause(string) string = wrap_or_clause(string)
return string return string
def main():
# TODO: Migrate to unittests
print("Starting...")
print(pre_process_string("1 tsp. Diamond Crystal or ½ tsp. Morton kosher salt, plus more"))
print(pre_process_string("1 tsp. Diamond Crystal or ½ tsp. Morton kosher salt"))
print(pre_process_string("¼ cup michiu tou or other rice wine"))
print(pre_process_string("1 tbs. wine, expensive or other white wine, plus more"))
print("Finished...")
if __name__ == "__main__":
main()

View File

@ -12,6 +12,14 @@ CWD = Path(__file__).parent
MODEL_PATH = CWD / "model.crfmodel" MODEL_PATH = CWD / "model.crfmodel"
class CRFConfidence(BaseModel):
average: float = 0.0
comment: float = None
name: float = None
unit: float = None
qty: float = None
class CRFIngredient(BaseModel): class CRFIngredient(BaseModel):
input: str = "" input: str = ""
name: str = "" name: str = ""
@ -19,15 +27,19 @@ class CRFIngredient(BaseModel):
qty: str = "" qty: str = ""
comment: str = "" comment: str = ""
unit: str = "" unit: str = ""
confidence: CRFConfidence
@validator("qty", always=True, pre=True) @validator("qty", always=True, pre=True)
def validate_qty(qty, values): # sourcery skip: merge-nested-ifs def validate_qty(qty, values): # sourcery skip: merge-nested-ifs
if qty is None or qty == "": if qty is None or qty == "":
# Check if other contains a fraction # Check if other contains a fraction
try:
if values["other"] is not None and values["other"].find("/") != -1: if values["other"] is not None and values["other"].find("/") != -1:
return float(Fraction(values["other"])).__round__(1) return float(Fraction(values["other"])).__round__(1)
else: else:
return 1 return 1
except Exception:
pass
return qty return qty

View File

@ -1,4 +1,5 @@
import re import re
from statistics import mean
from . import tokenizer from . import tokenizer
@ -179,6 +180,9 @@ def import_data(lines):
data = [{}] data = [{}]
display = [[]] display = [[]]
prevTag = None prevTag = None
confidence_all = [{}]
# #
# iterate lines in the data file, which looks like: # iterate lines in the data file, which looks like:
# #
@ -208,6 +212,8 @@ def import_data(lines):
display.append([]) display.append([])
prevTag = None prevTag = None
confidence_all.append({})
# ignore comments # ignore comments
elif line[0] == "#": elif line[0] == "#":
pass pass
@ -226,6 +232,18 @@ def import_data(lines):
tag, confidence = re.split(r"/", columns[-1], 1) tag, confidence = re.split(r"/", columns[-1], 1)
tag = re.sub("^[BI]\-", "", tag).lower() # noqa: W605 - invalid dscape sequence tag = re.sub("^[BI]\-", "", tag).lower() # noqa: W605 - invalid dscape sequence
# ====================
# Confidence Getter
if prevTag != tag:
if confidence_all[-1].get(tag):
confidence_all[-1][tag].append(confidence)
else:
confidence_all[-1][tag] = [confidence]
else:
if confidence_all[-1].get(tag):
confidence_all[-1][tag].append(confidence)
else:
confidence_all[-1][tag] = [confidence]
# ---- DISPLAY ---- # ---- DISPLAY ----
# build a structure which groups each token by its tag, so we can # build a structure which groups each token by its tag, so we can
# rebuild the original display name later. # rebuild the original display name later.
@ -257,13 +275,23 @@ def import_data(lines):
output = [ output = [
dict([(k, smartJoin(tokens)) for k, tokens in ingredient.items()]) for ingredient in data if len(ingredient) dict([(k, smartJoin(tokens)) for k, tokens in ingredient.items()]) for ingredient in data if len(ingredient)
] ]
# Add the marked-up display data
for i, v in enumerate(output): # Preclean Confidence
output[i]["display"] = displayIngredient(display[i]) for i, c in enumerate(confidence_all):
avg_of_all = []
for k, v in c.items():
v = [float(x) for x in v]
avg = round(mean(v), 2)
avg_of_all.append(avg)
confidence_all[i][k] = avg
if avg_of_all:
confidence_all[i]["average"] = round(mean(avg_of_all), 2)
# Add the raw ingredient phrase # Add the raw ingredient phrase
for i, v in enumerate(output): for i, _ in enumerate(output):
output[i]["input"] = smartJoin([" ".join(tokens) for k, tokens in display[i]]) output[i]["input"] = smartJoin([" ".join(tokens) for _, tokens in display[i]])
output[i]["confidence"] = confidence_all[i]
return output return output

View File

@ -3,9 +3,15 @@ from fractions import Fraction
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.schema.recipe import RecipeIngredient from mealie.schema.recipe import RecipeIngredient
from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, CreateIngredientUnit from mealie.schema.recipe.recipe_ingredient import (
CreateIngredientFood,
CreateIngredientUnit,
IngredientConfidence,
ParsedIngredient,
RegisteredParser,
)
from .crfpp.processor import CRFIngredient, convert_list_to_crf_model from . import brute, crfpp
logger = get_logger(__name__) logger = get_logger(__name__)
@ -15,12 +21,41 @@ class ABCIngredientParser(ABC):
Abstract class for ingredient parsers. Abstract class for ingredient parsers.
""" """
def parse_one(self, ingredient_string: str) -> ParsedIngredient:
pass
@abstractmethod @abstractmethod
def parse(self, ingredients: list[str]) -> list[RecipeIngredient]: def parse(self, ingredients: list[str]) -> list[ParsedIngredient]:
... ...
class CRFPPIngredientParser(ABCIngredientParser): 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(
input=ingredient,
ingredient=RecipeIngredient(
unit=CreateIngredientUnit(name=bfi.unit),
food=CreateIngredientFood(name=bfi.food),
disable_amount=False,
quantity=bfi.amount,
note=bfi.note,
),
)
def parse(self, ingredients: list[str]) -> list[ParsedIngredient]:
return [self.parse_one(ingredient) for ingredient in ingredients]
class NLPParser(ABCIngredientParser):
""" """
Class for CRFPP ingredient parsers. Class for CRFPP ingredient parsers.
""" """
@ -28,7 +63,7 @@ class CRFPPIngredientParser(ABCIngredientParser):
def __init__(self) -> None: def __init__(self) -> None:
pass pass
def _crf_to_ingredient(self, crf_model: CRFIngredient) -> RecipeIngredient: def _crf_to_ingredient(self, crf_model: crfpp.CRFIngredient) -> ParsedIngredient:
ingredient = None ingredient = None
try: try:
@ -41,15 +76,37 @@ class CRFPPIngredientParser(ABCIngredientParser):
quantity=float(sum(Fraction(s) for s in crf_model.qty.split())), quantity=float(sum(Fraction(s) for s in crf_model.qty.split())),
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to parse ingredient: {crf_model}: {e}")
# TODO: Capture some sort of state for the user to see that an exception occured # TODO: Capture some sort of state for the user to see that an exception occured
logger.exception(e)
ingredient = RecipeIngredient( ingredient = RecipeIngredient(
title="", title="",
note=crf_model.input, note=crf_model.input,
) )
return ingredient return ParsedIngredient(
input=crf_model.input,
ingredient=ingredient,
confidence=IngredientConfidence(
quantity=crf_model.confidence.qty,
food=crf_model.confidence.name,
**crf_model.confidence.dict(),
),
)
def parse(self, ingredients: list[str]) -> list[RecipeIngredient]: def parse(self, ingredients: list[str]) -> list[ParsedIngredient]:
crf_models = convert_list_to_crf_model(ingredients) crf_models = crfpp.convert_list_to_crf_model(ingredients)
return [self._crf_to_ingredient(crf_model) for crf_model in crf_models] return [self._crf_to_ingredient(crf_model) for crf_model in crf_models]
__registrar = {
RegisteredParser.nlp: NLPParser,
RegisteredParser.brute: BruteForceParser,
}
def get_parser(parser: RegisteredParser) -> ABCIngredientParser:
"""
get_parser returns an ingrdeint parser based on the string enum value
passed in.
"""
return __registrar.get(parser, NLPParser)()

View File

@ -1,14 +1,19 @@
from mealie.schema.recipe import RecipeIngredient from mealie.schema.recipe import RecipeIngredient
from mealie.services._base_http_service.http_services import UserHttpService from mealie.services._base_http_service.http_services import UserHttpService
from .ingredient_parser import ABCIngredientParser, CRFPPIngredientParser from .ingredient_parser import ABCIngredientParser, RegisteredParser, get_parser
class IngredientParserService(UserHttpService): class IngredientParserService(UserHttpService):
def __init__(self, parser: ABCIngredientParser = None, *args, **kwargs) -> None: parser: ABCIngredientParser
self.parser: ABCIngredientParser = parser() if parser else CRFPPIngredientParser()
def __init__(self, parser: RegisteredParser = RegisteredParser.nlp, *args, **kwargs) -> None:
self.set_parser(parser)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def set_parser(self, parser: RegisteredParser) -> None:
self.parser = get_parser(parser)
def populate_item(self) -> None: def populate_item(self) -> None:
"""Satisfy abstract method""" """Satisfy abstract method"""
pass pass

View File

@ -3,6 +3,7 @@ from fractions import Fraction
import pytest import pytest
from mealie.services.parser_services import RegisteredParser, get_parser
from mealie.services.parser_services.crfpp.processor import CRFIngredient, convert_list_to_crf_model from mealie.services.parser_services.crfpp.processor import CRFIngredient, convert_list_to_crf_model
@ -15,6 +16,12 @@ class TestIngredient:
comments: str comments: str
def crf_exists() -> bool:
import shutil
return shutil.which("crf_test") is not None
# TODO - add more robust test cases # TODO - add more robust test cases
test_ingredients = [ test_ingredients = [
TestIngredient("½ cup all-purpose flour", 0.5, "cup", "all-purpose flour", ""), TestIngredient("½ cup all-purpose flour", 0.5, "cup", "all-purpose flour", ""),
@ -24,12 +31,6 @@ test_ingredients = [
] ]
def crf_exists() -> bool:
import shutil
return shutil.which("crf_test") is not None
@pytest.mark.skipif(not crf_exists(), reason="CRF++ not installed") @pytest.mark.skipif(not crf_exists(), reason="CRF++ not installed")
def test_nlp_parser(): def test_nlp_parser():
models: list[CRFIngredient] = convert_list_to_crf_model([x.input for x in test_ingredients]) models: list[CRFIngredient] = convert_list_to_crf_model([x.input for x in test_ingredients])
@ -41,3 +42,34 @@ def test_nlp_parser():
assert model.comment == test_ingredient.comments assert model.comment == test_ingredient.comments
assert model.name == test_ingredient.food assert model.name == test_ingredient.food
assert model.unit == test_ingredient.unit assert model.unit == test_ingredient.unit
def test_brute_parser():
# input: (quantity, unit, food, comments)
expectations = {
# Dutch
"1 theelepel koffie": (1, "theelepel", "koffie", ""),
"3 theelepels koffie": (3, "theelepels", "koffie", ""),
"1 eetlepel tarwe": (1, "eetlepel", "tarwe", ""),
"20 eetlepels bloem": (20, "eetlepels", "bloem", ""),
"1 mespunt kaneel": (1, "mespunt", "kaneel", ""),
"1 snuf(je) zout": (1, "snuf(je)", "zout", ""),
"2 tbsp minced cilantro, leaves and stems": (2, "tbsp", "minced cilantro", "leaves and stems"),
"1 large yellow onion, coarsely chopped": (1, "large", "yellow onion", "coarsely chopped"),
"1 1/2 tsp garam masala": (1.5, "tsp", "garam masala", ""),
"2 cups mango chunks, (2 large mangoes) (fresh or frozen)": (
2,
"cups",
"mango chunks, (2 large mangoes)",
"fresh or frozen",
),
}
parser = get_parser(RegisteredParser.brute)
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}