-
-
Notifications
You must be signed in to change notification settings - Fork 779
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
base: mealie-next
Are you sure you want to change the base?
Changes from all commits
140ddf9
c287c79
648fdd8
9b0d113
5dc050d
a9a233c
4cf74f9
e0be273
f4fab70
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: |
||
<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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> |
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 |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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.""" | ||||||
|
||||||
|
@@ -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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.