mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
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:
parent
60908e5a88
commit
3b920babe3
@ -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
|
||||||
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
82
frontend/components/global/BaseOverflowButton.vue
Normal file
82
frontend/components/global/BaseOverflowButton.vue
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
// @ts-ignore
|
properties.comment.value = data.ingredient.note || "";
|
||||||
properties.comments.value = data.ingredient.note || null;
|
properties.quantity.value = data.ingredient.quantity || "";
|
||||||
// @ts-ignore
|
properties.unit.value = data.ingredient.unit.name || "";
|
||||||
properties.quantity.value = data.ingredient.quantity || null;
|
properties.food.value = data.ingredient.food.name || "";
|
||||||
// @ts-ignore
|
|
||||||
properties.unit.value = data.ingredient.unit.name || null;
|
for (const property in properties) {
|
||||||
// @ts-ignore
|
const color = getColor(property);
|
||||||
properties.food.value = data.ingredient.food.name || null;
|
const confidence = getConfidence(property);
|
||||||
|
if (color) {
|
||||||
|
// @ts-ignore
|
||||||
|
properties[property].color = color;
|
||||||
|
}
|
||||||
|
if (confidence) {
|
||||||
|
// @ts-ignore
|
||||||
|
properties[property].confidence = confidence;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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,
|
||||||
|
@ -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 {
|
||||||
|
ingredientIndex: index,
|
||||||
|
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 {
|
return {
|
||||||
...toRefs(state),
|
parser,
|
||||||
api,
|
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>
|
|
@ -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,
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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()
|
||||||
|
@ -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
33
mealie/routes/handlers.py
Normal 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
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -1 +1,2 @@
|
|||||||
|
from .ingredient_parser import *
|
||||||
from .ingredient_parser_service import *
|
from .ingredient_parser_service import *
|
||||||
|
1
mealie/services/parser_services/_helpers/__init__.py
Normal file
1
mealie/services/parser_services/_helpers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .string_utils import *
|
23
mealie/services/parser_services/_helpers/string_utils.py
Normal file
23
mealie/services/parser_services/_helpers/string_utils.py
Normal 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)
|
1
mealie/services/parser_services/brute/__init__.py
Normal file
1
mealie/services/parser_services/brute/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .process import parse
|
204
mealie/services/parser_services/brute/process.py
Normal file
204
mealie/services/parser_services/brute/process.py
Normal 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)
|
@ -0,0 +1 @@
|
|||||||
|
from .processor import *
|
@ -2,23 +2,25 @@ import re
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
replace_abbreviations = {
|
replace_abbreviations = {
|
||||||
"cup ": "cup ",
|
"cup": " cup ",
|
||||||
" g ": "gram ",
|
"g": " gram ",
|
||||||
"kg ": "kilogram ",
|
"kg": " kilogram ",
|
||||||
"lb ": "pound ",
|
"lb": " pound ",
|
||||||
"ml ": "milliliter ",
|
"ml": " milliliter ",
|
||||||
"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()
|
|
||||||
|
@ -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
|
||||||
if values["other"] is not None and values["other"].find("/") != -1:
|
try:
|
||||||
return float(Fraction(values["other"])).__round__(1)
|
if values["other"] is not None and values["other"].find("/") != -1:
|
||||||
else:
|
return float(Fraction(values["other"])).__round__(1)
|
||||||
return 1
|
else:
|
||||||
|
return 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return qty
|
return qty
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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)()
|
||||||
|
@ -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
|
||||||
|
@ -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}
|
Loading…
x
Reference in New Issue
Block a user