Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Parse and convert units using openAI #4508

Open
wants to merge 9 commits into
base: mealie-next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions frontend/lib/api/user/recipes/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const routes = {
recipesCategory: `${prefix}/recipes/category`,
recipesParseIngredient: `${prefix}/parser/ingredient`,
recipesParseIngredients: `${prefix}/parser/ingredients`,
recipesConvertUnits: `${prefix}/parser/convert-units`,
recipesTimelineEvent: `${prefix}/recipes/timeline/events`,

recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`,
Expand Down Expand Up @@ -175,6 +176,11 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
return await this.requests.post<ParsedIngredient[]>(routes.recipesParseIngredients, { parser, ingredients });
}

async convertUnits(parser: Parser, ingredients: Array<string>, convert_to: "metric" | "imperial") {
parser = "openai";
return await this.requests.post<ParsedIngredient[]>(routes.recipesConvertUnits, { parser, ingredients, convert_to });
}

async parseIngredient(parser: Parser, ingredient: string) {
parser = parser || "nlp";
return await this.requests.post<ParsedIngredient>(routes.recipesParseIngredient, { parser, ingredient });
Expand Down
37 changes: 37 additions & 0 deletions frontend/pages/g/_groupSlug/r/_slug/ingredient-parser.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@

<div class="d-flex mt-n3 mb-4 justify-end" style="gap: 5px">
<BaseButton cancel class="mr-auto" @click="$router.go(-1)"></BaseButton>
<BaseButton color="info" :disabled="parserLoading" @click="convertUnits">
<template #icon> {{ $globals.icons.cog}}</template>
Parse and convert units (Metric)
</BaseButton>
<BaseButton color="info" :disabled="parserLoading" @click="fetchParsed">
<template #icon> {{ $globals.icons.foods }}</template>
{{ $tc("recipe.parser.parse-all") }}
Expand Down Expand Up @@ -248,6 +252,38 @@ export default defineComponent({
}
}

async function convertUnits() {
if (!recipe.value || !recipe.value.recipeIngredient) {
return;
}
const raw = recipe.value.recipeIngredient.map((ing) => ing.note ?? "");

parserLoading.value = true;
const { data } = await api.recipes.convertUnits(parser.value, raw, "metric");
parserLoading.value = false;

if (data) {
// When we send the recipe ingredient text to be parsed, we lose the reference to the original unparsed ingredient.
// Generally this is fine, but if the unparsed ingredient had a title, we lose it; we add back the title for each ingredient here.
try {
for (let i = 0; i < recipe.value.recipeIngredient.length; i++) {
data[i].ingredient.title = recipe.value.recipeIngredient[i].title;
}
} catch (TypeError) {
console.error("Index Mismatch Error during recipe ingredient parsing; did the number of ingredients change?");
}

parsedIng.value = data;

errors.value = data.map((ing, index: number) => {
return processIngredientError(ing, index);
});
} else {
alert.error(i18n.t("events.something-went-wrong") as string);
parsedIng.value = [];
}
}

function isError(ing: ParsedIngredient) {
if (!ing?.confidence?.average) {
return true;
Expand Down Expand Up @@ -386,6 +422,7 @@ export default defineComponent({
panels,
asPercentage,
fetchParsed,
convertUnits,
parsedIng,
recipe,
loading,
Expand Down
7 changes: 6 additions & 1 deletion mealie/routes/parser/ingredient_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from mealie.routes._base import BaseUserController, controller
from mealie.schema.recipe import ParsedIngredient
from mealie.schema.recipe.recipe_ingredient import IngredientRequest, IngredientsRequest
from mealie.schema.recipe.recipe_ingredient import IngredientRequest, IngredientsConvertRequest, IngredientsRequest
from mealie.services.parser_services import get_parser

router = APIRouter(prefix="/parser")
Expand All @@ -20,3 +20,8 @@ async def parse_ingredient(self, ingredient: IngredientRequest):
async def parse_ingredients(self, ingredients: IngredientsRequest):
parser = get_parser(ingredients.parser, self.group_id, self.session)
return await parser.parse(ingredients.ingredients)

@router.post("/convert-units", response_model=list[ParsedIngredient])
async def parse_and_convert_ingredients(self, ingredients: IngredientsConvertRequest):
parser = get_parser(ingredients.parser, self.group_id, self.session)
return await parser.convert_units(ingredients.ingredients, ingredients.convert_to.value)
11 changes: 11 additions & 0 deletions mealie/schema/recipe/recipe_ingredient.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,17 @@ class IngredientsRequest(MealieModel):
ingredients: list[str]


class ConvertTo(enum.Enum):
metric = "metric"
imperial = "imperial"


class IngredientsConvertRequest(MealieModel):
parser: RegisteredParser = RegisteredParser.openai
ingredients: list[str]
convert_to: ConvertTo


class IngredientRequest(MealieModel):
parser: RegisteredParser = RegisteredParser.nlp
ingredient: str
Expand Down
23 changes: 23 additions & 0 deletions mealie/services/openai/prompts/recipes/convert-recipe-units.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
You are a bot that parses and converts units of ingredients in recipes. You will receive a list of one or more ingredients, each containing one or more of the following components: quantity, unit, food, and note. Their definitions are stated in the JSON schema below. While parsing the ingredients, there are some things to keep in mind:
- If you cannot accurately determine the quantity, unit, food, or note, you should place everything into the note field and leave everything else empty. It's better to err on the side of putting everything in the note field than being wrong
- You may receive recipe ingredients from multiple different languages. You should adhere to the grammar rules of the input language when trying to parse the ingredient string
- Sometimes foods or units will be in their singular, plural, or other grammatical forms. You must interpret all of them appropriately
- Sometimes ingredients will have text in parenthesis (like this). Parenthesis typically indicate something that should appear in the notes. For example: an input of "3 potatoes (roughly chopped)" would parse "roughly chopped" into the notes. Notice that when this occurs, the parenthesis are dropped, and you should use "roughly chopped" instead of "(roughly chopped)" in the note
- It's possible for the input to contain typos. For instance, you might see the word "potatos" instead of "potatoes". If it is a common misspelling, you may correct it
- Pay close attention to what can be considered a unit of measurement. There are common measurements such as tablespoon, teaspoon, and gram, abbreviations such as tsp, tbsp, and oz, and others such as sprig, can, bundle, bunch, unit, cube, package, and pinch
- Sometimes quantities can be given a range, such as "3-5" or "1 to 2" or "three or four". In this instance, choose the lower quantity; do not try to average or otherwise calculate the quantity. For instance, if the input it "2-3 lbs of chicken breast" the quantity should be "2"
- Any text that does not appear in the unit or food must appear in the notes. No text should be left off. The only exception for this is if a quantity is converted from text into a number. For instance, if you convert "2 dozen" into the number "24", you should not put the word "dozen" into any other field

You will receive whether you should convert from imperial to metric or the other way around. These are the rules you should follow when converting the units:
- When converting from units such as cups you can approximate the answer.
- When converting the units you should use units such as liters, ml or cl for liquid ingredients. Example: 100ml of milk, 2l of stock.
- When converting the units you should use units such as grams, kg or mg for solid ingredients. Example: 10g of salt, 300gr of mozzarella.
- When converting round up the numbers which make sense depending on the size of the ingredient. For example, if you have 1/3 cup of sugar, you should round it to 70mg of sugar.
- You should convert temperature measurements to Celsius. Example: '350°F' should be '180°C'.
- You should also convert length measurements to measurements like meter, cm and mm. Example: '1/8 inch thick slices' should be '3mm thick slices'.
- You need to convert units and measurements which are in the notes field.
- Teaspoons and pinches you can keep the same.

It is imperative that you do not create any data or otherwise make up any information, however you may approximate the conversion. Failure to adhere to this rule is illegal and will result in harsh punishment. If you are unsure, place the entire string into the note section of the response. Do not make things up.

Below you will receive the JSON schema for your response. Your response must be in valid JSON in the below schema as provided. You must respond in this JSON schema; failure to do so is illegal. It is imperative that you follow the schema precisely to avoid punishment. You must follow the JSON schema.
3 changes: 3 additions & 0 deletions mealie/services/parser_services/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ async def parse_one(self, ingredient_string: str) -> ParsedIngredient: ...
@abstractmethod
async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: ...

@abstractmethod
async def convert_units(self, ingredients: list[str], user_prompt: str) -> list[ParsedIngredient]: ...

def find_ingredient_match(self, ingredient: ParsedIngredient) -> ParsedIngredient:
if ingredient.ingredient.food and (food_match := self.data_matcher.find_food_match(ingredient.ingredient.food)):
ingredient.ingredient.food = food_match
Expand Down
48 changes: 48 additions & 0 deletions mealie/services/parser_services/openai/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from mealie.schema.openai.recipe_ingredient import OpenAIIngredient, OpenAIIngredients
from mealie.schema.recipe.recipe_ingredient import (
ConvertTo,
CreateIngredientFood,
CreateIngredientUnit,
IngredientConfidence,
Expand Down Expand Up @@ -108,3 +109,50 @@ async def parse_one(self, ingredient_string: str) -> ParsedIngredient:
async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]:
response = await self._parse(ingredients)
return [self._convert_ingredient(ing) for ing in response.ingredients]

async def convert_units(self, ingredients: list[str], convert_to: ConvertTo) -> list[ParsedIngredient]:
service = OpenAIService()
data_injections = [
OpenAIDataInjection(
description=(
"This is the JSON response schema. You must respond in valid JSON that follows this schema. "
"Your payload should be as compact as possible, eliminating unncessesary whitespace. Any fields "
"with default values which you do not populate should not be in the payload."
),
value=OpenAIIngredients,
),
OpenAIDataInjection(
description="The direction to which you should convert. You should convert to:", value=convert_to
),
]
prompt = service.get_prompt("recipes.convert-recipe-units", data_injections=data_injections)
# chunk ingredients and send each chunk to its own worker
ingredient_chunks = self._chunk_messages(ingredients, n=service.workers)
tasks: list[Awaitable[str | None]] = []
for ingredient_chunk in ingredient_chunks:
message = json.dumps(ingredient_chunk, separators=(",", ":"))
tasks.append(service.get_response(prompt, message, force_json_response=True))

# re-combine chunks into one response
try:
responses_json = await asyncio.gather(*tasks)
except Exception as e:
raise Exception("Failed to call OpenAI services") from e

try:
responses = [
OpenAIIngredients.parse_openai_response(response_json)
for response_json in responses_json
if responses_json
]
except Exception as e:
raise Exception("Failed to parse OpenAI response") from e

if not responses:
raise Exception("No response from OpenAI")

res = OpenAIIngredients(
ingredients=[ingredient for response in responses for ingredient in response.ingredients]
)

return [self._convert_ingredient(ing) for ing in res.ingredients]