1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-02 20:15:24 +02: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:
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

@ -22,6 +22,43 @@ const routes = {
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> {
baseRoute: string = routes.recipesBase;
itemRoute = routes.recipesRecipeSlug;
@ -84,11 +121,13 @@ export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
return await this.requests.delete(routes.recipesSlugCommentsId(slug, id));
}
async parseIngredients(ingredients: Array<string>) {
return await this.requests.post(routes.recipesParseIngredients, { ingredients });
async parseIngredients(parser: Parser, ingredients: Array<string>) {
parser = parser || "nlp";
return await this.requests.post<ParsedIngredient[]>(routes.recipesParseIngredients, { parser, ingredients });
}
async parseIngredient(ingredient: string) {
return await this.requests.post(routes.recipesParseIngredient, { ingredient });
async parseIngredient(parser: Parser, ingredient: string) {
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>
<v-container>
<v-container class="pa-0">
<v-container>
<BaseCardSectionTitle title="Ingredients Natural Language Processor">
Mealie uses conditional random Conditional Random Fields (CRFs) for parsing and processing ingredients. The
model used for ingredients is based off a data set of over 100,000 ingredients from a dataset compiled by the
New York Times. Note that as the model is trained in English only, you may have varied results when using the
model in other languages. This page is a playground for testing the model.
Mealie uses Conditional Random Fields (CRFs) for parsing and processing ingredients. The model used for
ingredients is based off a data set of over 100,000 ingredients from a dataset compiled by the New York Times.
Note that as the model is trained in English only, you may have varied results when using the model in other
languages. This page is a playground for testing the model.
<p class="pt-3">
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>
</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-text>
<v-text-field v-model="ingredient" label="Ingredient Text"> </v-text-field>
@ -26,22 +36,29 @@
</v-card>
</v-container>
<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">
<v-col v-if="prop.value" :key="index" xs="12" sm="6" lg="3">
<v-card>
<div v-if="prop.value" :key="index" class="flex-grow-1">
<v-card min-width="200px">
<v-card-title> {{ prop.value }} </v-card-title>
<v-card-text>
{{ prop.subtitle }}
</v-card-text>
</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>
</v-row>
</div>
</v-container>
<v-container class="narrow-container">
<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-text> {{ text }} </v-card-text>
</v-card>
@ -50,7 +67,8 @@
</template>
<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";
export default defineComponent({
@ -62,8 +80,41 @@ export default defineComponent({
loading: false,
ingredient: "",
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 = [
"2 tbsp minced cilantro, leaves and stems",
"1 large yellow onion, coarsely chopped",
@ -78,23 +129,39 @@ export default defineComponent({
}
async function processIngredient() {
if (state.ingredient === "") {
return;
}
state.loading = true;
const { data } = await api.recipes.parseIngredient(state.ingredient);
const { data } = await api.recipes.parseIngredient(state.parser, state.ingredient);
if (data) {
state.results = true;
confidence.value = data.confidence;
// TODO: Remove ts-ignore
// 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
// @ts-ignore
properties.comments.value = data.ingredient.note || null;
// @ts-ignore
properties.quantity.value = data.ingredient.quantity || null;
// @ts-ignore
properties.unit.value = data.ingredient.unit.name || null;
// @ts-ignore
properties.food.value = data.ingredient.food.name || null;
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
properties[property].color = color;
}
if (confidence) {
// @ts-ignore
properties[property].confidence = confidence;
}
}
}
state.loading = false;
}
@ -102,23 +169,37 @@ export default defineComponent({
const properties = reactive({
quantity: {
subtitle: "Quantity",
value: "Value",
value: "" as any,
color: null,
confidence: null,
},
unit: {
subtitle: "Unit",
value: "Value",
value: "",
color: null,
confidence: null,
},
food: {
subtitle: "Food",
value: "Value",
value: "",
color: null,
confidence: null,
},
comments: {
subtitle: "Comments",
value: "Value",
comment: {
subtitle: "Comment",
value: "",
color: null,
confidence: null,
},
});
const showConfidence = ref(false);
return {
showConfidence,
getColor,
confidence,
getConfidence,
...toRefs(state),
tryText,
properties,

View file

@ -1,23 +1,69 @@
<template>
<v-container v-if="recipe">
<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">
<BaseButton color="info">
<BaseButton color="info" @click="fetchParsed">
<template #icon> {{ $globals.icons.foods }}</template>
Parse All
</BaseButton>
<BaseButton save> Save All </BaseButton>
<BaseButton save @click="saveAll"> Save All </BaseButton>
</v-card-actions>
</v-card>
<v-expansion-panels v-model="panels" multiple>
<v-expansion-panel v-for="(ing, index) in ingredients" :key="index">
<v-expansion-panel-header class="my-0 py-0">
{{ recipe.recipeIngredient[index].note }}
<v-expansion-panel v-for="(ing, index) in parsedIng" :key="index">
<v-expansion-panel-header class="my-0 py-0" disable-icon-rotate>
{{ 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-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>
</v-expansion-panels>
@ -26,19 +72,32 @@
</template>
<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 { useApiSingleton } from "~/composables/use-api";
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({
components: {
RecipeIngredientEditor,
},
setup() {
const state = reactive({
panels: null,
});
const panels = ref<number[]>([]);
const route = useRoute();
const router = useRouter();
const slug = route.value.params.slug;
const api = useApiSingleton();
@ -48,14 +107,150 @@ export default defineComponent({
const ingredients = ref<any[]>([]);
watch(recipe, () => {
const copy = recipe?.value?.recipeIngredient || [];
ingredients.value = [...copy];
});
// =========================================================
// Parser Logic
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 {
...toRefs(state),
api,
parser,
saveAll,
createFood,
errors,
actions,
workingFoodData,
isError,
panels,
asPercentage,
fetchParsed,
parsedIng,
recipe,
loading,
ingredients,
@ -69,5 +264,3 @@ export default defineComponent({
});
</script>
<style scoped>
</style>

View file

@ -7,11 +7,8 @@
<template #title> Recipe Creation </template>
Select one of the various ways to create a recipe
</BasePageTitle>
<v-tabs v-model="tab">
<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>
<BaseOverflowButton v-model="tab" rounded class="mx-2" outlined :items="tabs"> </BaseOverflowButton>
<section>
<v-tabs-items v-model="tab" class="mt-10">
<v-tab-item value="url" eager>
@ -127,7 +124,7 @@
</template>
<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 { validators } from "~/composables/use-validators";
export default defineComponent({
@ -137,6 +134,27 @@ export default defineComponent({
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 router = useRouter();
@ -203,6 +221,7 @@ export default defineComponent({
}
return {
tabs,
domCreateByName,
domUrlForm,
newRecipeName,

View file

@ -10,11 +10,11 @@
<section>
<ToggleState tag="article">
<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>
{{ $t("settings.change-password") }}
</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>
{{ $t("settings.profile") }}
</v-btn>

View file

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