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: Add tts / text-based recipe import feature #4561

Open
wants to merge 9 commits into
base: mealie-next
Choose a base branch
from
Open
6 changes: 6 additions & 0 deletions frontend/lang/messages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,12 @@
"import-with-zip": "Import with .zip",
"create-recipe-from-an-image": "Create Recipe from an Image",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.",
"import-from-plaintext": "Import Recipe from Plain Text",
"create-recipe-from-text": "Create Recipe from Text",
"create-recipe-from-text-description": "Type out your recipe in plain text from any source, and we'll send it to your OpenAI service to create a recipe from it. Be sure to include everything needed to create the recipe (the recipe name, description, all of the steps and ingredients, etc.). If your device supports it, you can even use text-to-speech and speak your recipe out loud.",
"enter-recipe-text": "Enter Recipe Text",
"recipe-text-placeholder": "Paste or type your recipe here...",
"please-wait-processing": "Please wait while we process your recipe...",
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
"create-from-image": "Create from Image",
"should-translate-description": "Translate the recipe into my language",
Expand Down
13 changes: 13 additions & 0 deletions frontend/lib/api/user/recipes/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const routes = {
recipesCreateUrlBulk: `${prefix}/recipes/create/url/bulk`,
recipesCreateFromZip: `${prefix}/recipes/create/zip`,
recipesCreateFromImage: `${prefix}/recipes/create/image`,
recipesCreateFromText: `${prefix}/recipes/create/text`,
recipesCreateFromHtmlOrJson: `${prefix}/recipes/create/html-or-json`,
recipesCategory: `${prefix}/recipes/category`,
recipesParseIngredient: `${prefix}/parser/ingredient`,
Expand Down Expand Up @@ -160,6 +161,18 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
return await this.requests.post<string>(apiRoute, formData);
}

async createOneFromText(text: string, translateLanguage: string | null = null) {
const formData = new FormData();
formData.append("text", text);

let apiRoute = routes.recipesCreateFromText;
if (translateLanguage) {
apiRoute = `${apiRoute}?translateLanguage=${translateLanguage}`;
}

return await this.requests.post<string>(apiRoute, formData);
}

async parseIngredients(parser: Parser, ingredients: Array<string>) {
parser = parser || "nlp";
return await this.requests.post<ParsedIngredient[]>(routes.recipesParseIngredients, { parser, ingredients });
Expand Down
1 change: 1 addition & 0 deletions frontend/lib/icons/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ export const icons = {
star: mdiStar,
stop: mdiStop,
testTube: mdiTestTube,
text: mdiText,
timelineText: mdiTimelineText,
timer: mdiTimer,
timerPause: mdiTimerPause,
Expand Down
8 changes: 8 additions & 0 deletions frontend/pages/g/_groupSlug/r/create.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default defineComponent({
const { $auth, $globals, i18n } = useContext();

const appInfo = useAppInfo();
const enableOpenAI = computed(() => appInfo.value?.enableOpenai);
const enableOpenAIImages = computed(() => appInfo.value?.enableOpenaiImageServices);

const subpages = computed<MenuItem[]>(() => [
Expand All @@ -57,6 +58,13 @@ export default defineComponent({
text: i18n.tc("recipe.import-from-html-or-json"),
value: "html",
},
{
// just textarea dump voice actived icon
icon: $globals.icons.text,
Comment on lines +61 to +63
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{
// just textarea dump voice actived icon
icon: $globals.icons.text,
{
icon: $globals.icons.text,

text: i18n.tc("recipe.import-from-plaintext"),
value: "plaintext",
hide: !enableOpenAI.value,
},
{
icon: $globals.icons.fileImage,
text: i18n.tc("recipe.create-from-image"),
Expand Down
103 changes: 103 additions & 0 deletions frontend/pages/g/_groupSlug/r/create/plaintext.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<template>
<div>
<v-form ref="domUrlForm" @submit.prevent="createRecipe">
<div>
<v-card-title class="headline">{{ $t('recipe.create-recipe-from-text') }}</v-card-title>
<v-card-text>
<p>{{ $t('recipe.create-recipe-from-text-description') }}</p>
<v-container class="pa-0">
<v-row>
<v-col cols="12">
<v-textarea
v-model="recipeText"
:label="$t('recipe.enter-recipe-text')"
:placeholder="$t('recipe.recipe-text-placeholder')"
itsrubberduck marked this conversation as resolved.
Show resolved Hide resolved
outlined
auto-grow
rows="6"
:disabled="loading"
></v-textarea>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-actions v-if="recipeText">
Copy link
Collaborator

@michael-genson michael-genson Nov 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of hiding this, can we disable the create button? It's a bit jarring to see it pop in and out. Something like:
<BaseButton :disabled="!recipeText" ... />

<div>
<p style="width: 250px">
<BaseButton rounded block type="submit" :loading="loading" />
</p>
<p>
<v-checkbox
v-model="shouldTranslate"
hide-details
:label="$t('recipe.should-translate-description')"
:disabled="loading"
/>
</p>
<p v-if="loading" class="mb-0">
{{ $t('recipe.please-wait-processing') }}
</p>
Comment on lines +37 to +39
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can probably get rid of this since the submit button already indicates that it's loading

</div>
</v-card-actions>
</div>
</v-form>
</div>
</template>

<script lang="ts">
import {
computed,
defineComponent,
reactive,
ref,
toRefs,
useContext,
useRoute,
useRouter,
} from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { VForm } from "~/types/vuetify";

export default defineComponent({
setup() {
const state = reactive({
loading: false,
});

const { i18n } = useContext();
const api = useUserApi();
const route = useRoute();
const router = useRouter();
const groupSlug = computed(() => route.value.params.groupSlug || "");

const domUrlForm = ref<VForm | null>(null);
const recipeText = ref("");
const shouldTranslate = ref(true);

async function createRecipe() {
if (!recipeText.value.trim()) {
return;
}

state.loading = true;
const translateLanguage = shouldTranslate.value ? i18n.locale : undefined;
const { data, error } = await api.recipes.createOneFromText(recipeText.value, translateLanguage);
if (error || !data) {
alert.error(i18n.tc("events.something-went-wrong"));
state.loading = false;
} else {
router.push(`/g/${groupSlug.value}/r/${data}`);
}
}

return {
...toRefs(state),
domUrlForm,
recipeText,
shouldTranslate,
createRecipe,
};
},
});
</script>
29 changes: 29 additions & 0 deletions mealie/routes/recipe/recipe_crud_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import sqlalchemy
from fastapi import (
BackgroundTasks,
Body,
Depends,
File,
Form,
Expand Down Expand Up @@ -329,6 +330,34 @@ async def create_recipe_from_image(

return recipe.slug

@router.post("/create/text", status_code=201)
async def create_recipe_from_text(
self,
text: str = Body(..., description="The recipe text to process"),
translate_language: str | None = Query(None, alias="translateLanguage"),
):
"""
Create a recipe from text using OpenAI.
Optionally specify a language for it to translate the recipe to.
"""

if not self.settings.OPENAI_ENABLED:
raise HTTPException(
status_code=400,
detail=ErrorResponse.respond("OpenAI services are not enabled"),
)

recipe = await self.service.create_from_text(text, translate_language)

self.publish_event(
event_type=EventTypes.recipe_created,
document_data=EventRecipeData(operation=EventOperation.create, recipe_slug=recipe.slug),
group_id=recipe.group_id,
household_id=recipe.household_id,
)

return recipe.slug

# ==================================================================================================================
# CRUD Operations

Expand Down
16 changes: 16 additions & 0 deletions mealie/services/openai/prompts/recipes/parse-recipe-text.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
You are a bot that reads recipe text dictated via speech-to-text and parses it into recipe JSON. You will receive text from the user and you need to extract the recipe data and return its JSON in valid schema.

Try your best to extract recipe information from the provided text, even if it's not perfectly formatted. While you should avoid making up information that isn't there, you can make reasonable interpretations of unclear or ambiguous text to create a workable recipe.

Your response should be in JSON format following the Recipe schema (which will be provided separately). The goal is to create useful, structured recipe data while being flexible about the input format.

The user message will contain text for a single recipe. This could be in various formats - a block of text, list format, or informal description. Your job is to identify recipe components and organize them appropriately.

The text may not be in English. If the user requests a translation to another language, translate all recipe content. Otherwise, keep the original language.

Key points:
- Focus on extracting clearly stated information
- Be flexible with formatting but don't invent missing details
- Use reasonable interpretation for ambiguous text
- Preserve original language unless translation is requested
- Format according to the Recipe schema
64 changes: 64 additions & 0 deletions mealie/services/recipe/recipe_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,16 @@ async def create_from_images(self, images: list[UploadFile], translate_language:
data_service.write_image(f.read(), "webp")
return recipe

async def create_from_text(self, text: str, translate_language: str | None = None) -> Recipe:
openai_recipe_service = OpenAIRecipeService(self.repos, self.user, self.household, self.translator)

recipe_data = await openai_recipe_service.build_recipe_from_text(text, translate_language=translate_language)
recipe_data = cleaner.clean(recipe_data, self.translator)

recipe = self.create_one(recipe_data)

return recipe

def duplicate_one(self, old_slug_or_id: str | UUID, dup_data: RecipeDuplicate) -> Recipe:
"""Duplicates a recipe and returns the new recipe."""

Expand Down Expand Up @@ -503,3 +513,57 @@ async def build_recipe_from_images(self, images: list[Path], translate_language:
raise ValueError("Unable to parse recipe from image") from e

return recipe

async def build_recipe_from_text(self, text: str, translate_language: str | None) -> Recipe:
settings = get_app_settings()
if not settings.OPENAI_ENABLED:
raise ValueError("OpenAI services are not available")

openai_service = OpenAIService()
prompt = openai_service.get_prompt(
"recipes.parse-recipe-text",
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=OpenAIRecipe,
)
],
)

# Clean the input text
lines = text.split("\n")
cleaned_lines = [
line.strip()
for line in lines
if line.strip() and not line.startswith("---") and "Content-Disposition" not in line
]
cleaned_text = "\n".join(cleaned_lines)

message = "Please extract the recipe from the text provided and format it as a recipe."
message += "Create a meaningful name based on the ingredients."
message += " There should be exactly one recipe."
message += f" The text provided is: {cleaned_text}"

if translate_language:
message += f" Please translate the recipe to {translate_language}."

try:
# Explizit das model und response_format setzen
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Explizit das model und response_format setzen
# Explicitly set the model and response_format

response = await openai_service.get_response(prompt, message, force_json_response=True)

if not response or response == "{}":
raise ValueError("Empty response from OpenAI")

except Exception as e:
raise Exception("Failed to call OpenAI services") from e

try:
openai_recipe = OpenAIRecipe.parse_openai_response(response)
recipe = self._convert_recipe(openai_recipe)
return recipe
except Exception as e:
raise ValueError("Unable to parse recipe from text") from e
Loading
Loading