feat: OpenAI Ingredient Parsing (#3581)

This commit is contained in:
Michael Genson 2024-05-22 04:45:07 -05:00 committed by GitHub
parent 4c8bbdcde2
commit 5c57b3dd1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 789 additions and 274 deletions

View File

@ -26,7 +26,7 @@ Do the following for each recipe you want to intelligently handle ingredients.
6. Click the Edit button/icon again
7. Scroll to the ingredients and you should see new fields for Amount, Unit, Food, and Note. The Note in particular will contain the original text of the Recipe.
8. Click `Parse` and you will be taken to the ingredient parsing page.
9. Choose your parser. The `Natural Language Parser` works very well, but you can also use the `Brute Parser`.
9. Choose your parser. The `Natural Language Parser` works very well, but you can also use the `Brute Parser`, or the `OpenAI Parser` if you've [enabled OpenAI support](./installation/backend-config.md#openai).
10. Click `Parse All`, and your ingredients should be separated out into Units and Foods based on your seeding in Step 1 above.
11. For ingredients where the Unit or Food was not found, you can click a button to accept an automatically suggested Food to add to the database. Or, manually enter the Unit/Food and hit `Enter` (or click `Create`) to add it to the database
12. When done, click `Save All` and you will be taken back to the recipe. Now the Unit and Food fields of the recipe should be filled out.

View File

@ -102,6 +102,20 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc.md)
| OIDC_GROUPS_CLAIM | groups | Optional if not using `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. This is the claim Mealie will request from your IdP and will use to compare to `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP` to allow the user to log in to Mealie or is set as an admin. **Your IdP must be configured to grant this claim**|
| OIDC_TLS_CACERTFILE | None | File path to Certificate Authority used to verify server certificate (e.g. `/path/to/ca.crt`) |
### OpenAI
:octicons-tag-24: v1.7.0
Mealie supports various integrations using OpenAI. To enable OpenAI, [you must provide your OpenAI API key](https://platform.openai.com/api-keys). You can tweak how OpenAI is used using these backend settings. Please note that while OpenAI usage is optimized to reduce API costs, you're unlikely to be able to use OpenAI features with the free tier limits.
| Variables | Default | Description |
| ------------------------- | :------: | ------------------------------------------------------------------------------------------------------------------------------ |
| OPENAI_BASE_URL | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
| OPENAI_API_KEY | None | Your OpenAI API Key. Enables OpenAI-related features |
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
### Themeing
Setting the following environmental variables will change the theme of the frontend. Note that the themes are the same for all users. This is a break-change when migration from v0.x.x -> 1.x.x.

View File

@ -11,44 +11,50 @@
</v-icon>
</v-btn>
</template>
<!-- Model -->
<!-- Model -->
<v-list v-if="mode === MODES.model" dense>
<v-list-item-group v-model="itemGroup">
<template v-for="(item, index) in items">
<v-list-item :key="index" @click="setValue(item)">
<div v-if="!item.hide" :key="index">
<v-list-item @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-divider v-if="item.divider" :key="`divider-${index}`" class="my-1" ></v-divider>
</div>
</template>
</v-list-item-group>
</v-list>
<!-- Links -->
<v-list v-else-if="mode === MODES.link" dense>
<v-list-item-group v-model="itemGroup">
<template v-for="(item, index) in items">
<div v-if="!item.hide" :key="index">
<v-list-item :to="item.to">
<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-divider v-if="item.divider" :key="`divider-${index}`" class="my-1" ></v-divider>\
</div>
</template>
</v-list-item-group>
</v-list>
<!-- Event -->
<v-list v-else-if="mode === MODES.event" dense>
<template v-for="(item, index) in items">
<div v-if="!item.hide" :key="index">
<v-list-item @click="$emit(item.event)">
<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-divider v-if="item.divider" :key="`divider-${index}`" class="my-1" ></v-divider>
</template>
</v-list-item-group>
</v-list>
<!-- Links -->
<v-list v-else-if="mode === MODES.link" dense>
<v-list-item-group v-model="itemGroup">
<template v-for="(item, index) in items">
<v-list-item :key="index" :to="item.to">
<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-divider v-if="item.divider" :key="`divider-${index}`" class="my-1" ></v-divider>
</template>
</v-list-item-group>
</v-list>
<!-- Event -->
<v-list v-else-if="mode === MODES.event" dense>
<template v-for="(item, index) in items">
<v-list-item :key="index" @click="$emit(item.event)">
<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-divider v-if="item.divider" :key="`divider-${index}`" class="my-1" ></v-divider>
</div>
</template>
</v-list>
</v-menu>
@ -74,6 +80,7 @@ export interface MenuItem {
value?: string;
event?: string;
divider?: boolean;
hide?:boolean;
}
export default defineComponent({

View File

@ -1,6 +1,6 @@
import { Ref, useContext } from "@nuxtjs/composition-api";
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
import { TimelineEventType } from "~/lib/api/types/recipe";
import { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe";
export interface UserPrintPreferences {
imagePosition: string;
@ -36,6 +36,10 @@ export interface UserTimelinePreferences {
types: TimelineEventType[];
}
export interface UserParsingPreferences {
parser: RegisteredParser;
}
export function useUserPrintPreferences(): Ref<UserPrintPreferences> {
const fromStorage = useLocalStorage(
"recipe-print-preferences",
@ -116,3 +120,17 @@ export function useTimelinePreferences(): Ref<UserTimelinePreferences> {
return fromStorage;
}
export function useParsingPreferences(): Ref<UserParsingPreferences> {
const fromStorage = useLocalStorage(
"parsing-preferences",
{
parser: "nlp",
},
{ mergeDefaults: true }
// we cast to a Ref because by default it will return an optional type ref
// but since we pass defaults we know all properties are set.
) as unknown as Ref<UserParsingPreferences>;
return fromStorage;
}

View File

@ -594,6 +594,7 @@
"select-parser": "Select Parser",
"natural-language-processor": "Natural Language Processor",
"brute-parser": "Brute Parser",
"openai-parser": "OpenAI Parser",
"parse-all": "Parse All",
"no-unit": "No unit",
"missing-unit": "Create missing unit: {unit}",
@ -764,7 +765,10 @@
"recipe-scraper-version": "Recipe Scraper Version",
"oidc-ready": "OIDC Ready",
"oidc-ready-error-text": "Not all OIDC Values are configured. This can be ignored if you are not using OIDC Authentication.",
"oidc-ready-success-text": "Required OIDC variables are all set."
"oidc-ready-success-text": "Required OIDC variables are all set.",
"openai-ready": "OpenAI Ready",
"openai-ready-error-text": "Not all OpenAI Values are configured. This can be ignored if you are not using OpenAI features.",
"openai-ready-success-text": "Required OpenAI variables are all set."
},
"shopping-list": {
"all-lists": "All Lists",
@ -1170,6 +1174,7 @@
"ingredients-natural-language-processor-explanation-2": "It's not perfect, but it yields great results in general and is a good starting point for manually parsing ingredients into individual fields. Alternatively, you can also use the \"Brute\" processor that uses a pattern matching technique to identify ingredients.",
"nlp": "NLP",
"brute": "Brute",
"openai": "OpenAI",
"show-individual-confidence": "Show individual confidence",
"ingredient-text": "Ingredient Text",
"average-confident": "{0} Confident",

View File

@ -13,6 +13,7 @@ export interface AdminAboutInfo {
enableOidc: boolean;
oidcRedirect: boolean;
oidcProviderName: string;
enableOpenai: boolean;
versionLatest: string;
apiPort: number;
apiDocs: boolean;
@ -40,6 +41,7 @@ export interface AppInfo {
enableOidc: boolean;
oidcRedirect: boolean;
oidcProviderName: string;
enableOpenai: boolean;
}
export interface AppStartupInfo {
isFirstLogin: boolean;
@ -80,6 +82,7 @@ export interface CheckAppConfig {
emailReady: boolean;
ldapReady: boolean;
oidcReady: boolean;
enableOpenai: boolean;
baseUrlSet: boolean;
isUpToDate: boolean;
}

View File

@ -6,7 +6,7 @@
*/
export type ExportTypes = "json";
export type RegisteredParser = "nlp" | "brute";
export type RegisteredParser = "nlp" | "brute" | "openai";
export type TimelineEventType = "system" | "info" | "comment";
export type TimelineEventImage = "has image" | "does not have image";

View File

@ -17,7 +17,7 @@ import {
} from "~/lib/api/types/recipe";
import { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated";
export type Parser = "nlp" | "brute";
export type Parser = "nlp" | "brute" | "openai";
export interface CreateAsset {
name: string;

View File

@ -13,6 +13,7 @@
<v-btn-toggle v-model="parser" dense mandatory @change="processIngredient">
<v-btn value="nlp"> {{ $t('admin.nlp') }} </v-btn>
<v-btn value="brute"> {{ $t('admin.brute') }} </v-btn>
<v-btn value="openai"> {{ $t('admin.openai') }} </v-btn>
</v-btn-toggle>
<v-checkbox v-model="showConfidence" class="ml-5" :label="$t('admin.show-individual-confidence')"></v-checkbox>
@ -63,8 +64,8 @@
<script lang="ts">
import { defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
import { IngredientConfidence } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api";
import { IngredientConfidence } from "~/lib/api/types/recipe";
import { Parser } from "~/lib/api/user/recipes/recipe";
type ConfidenceAttribute = "average" | "comment" | "name" | "unit" | "quantity" | "food";

View File

@ -268,6 +268,15 @@ export default defineComponent({
color: appConfig.value.oidcReady ? goodColor : warningColor,
icon: appConfig.value.oidcReady ? goodIcon : warningIcon,
},
{
id: "openai-ready",
text: i18n.t("settings.openai-ready"),
status: appConfig.value.enableOpenai,
errorText: i18n.t("settings.openai-ready-error-text"),
successText: i18n.t("settings.openai-ready-success-text"),
color: appConfig.value.enableOpenai ? goodColor : warningColor,
icon: appConfig.value.enableOpenai ? goodIcon : warningIcon,
},
];
return data;
});

View File

@ -19,87 +19,93 @@
<BaseOverflowButton
v-model="parser"
btn-class="mx-2 mb-4"
:items="[
{
text: $tc('recipe.parser.natural-language-processor'),
value: 'nlp',
},
{
text: $tc('recipe.parser.brute-parser'),
value: 'brute',
},
]"
:items="availableParsers"
/>
</div>
</BaseCardSectionTitle>
<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" @click="fetchParsed">
<BaseButton color="info" :disabled="parserLoading" @click="fetchParsed">
<template #icon> {{ $globals.icons.foods }}</template>
{{ $tc("recipe.parser.parse-all") }}
</BaseButton>
<BaseButton save @click="saveAll" />
<BaseButton save :disabled="parserLoading" @click="saveAll" />
</div>
<v-expansion-panels v-model="panels" multiple>
<draggable
v-if="parsedIng.length > 0"
v-model="parsedIng"
handle=".handle"
:style="{ width: '100%' }"
ghost-class="ghost"
>
<v-expansion-panel v-for="(ing, index) in parsedIng" :key="index">
<v-expansion-panel-header class="my-0 py-0" disable-icon-rotate>
<template #default="{ open }">
<v-fade-transition>
<span v-if="!open" key="0"> {{ ing.input }} </span>
</v-fade-transition>
</template>
<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'">
{{ ing.confidence ? asPercentage(ing.confidence.average) : "" }}
</div>
</template>
</v-expansion-panel-header>
<v-expansion-panel-content class="pb-0 mb-0">
<RecipeIngredientEditor v-model="parsedIng[index].ingredient" allow-insert-ingredient @insert-ingredient="insertIngredient(index)" @delete="deleteIngredient(index)" />
{{ ing.input }}
<v-card-actions>
<v-spacer />
<BaseButton
v-if="errors[index].unitError && errors[index].unitErrorMessage !== ''"
color="warning"
small
@click="createUnit(ing.ingredient.unit, index)"
>
{{ errors[index].unitErrorMessage }}
</BaseButton>
<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>
</draggable>
</v-expansion-panels>
<div v-if="parserLoading">
<AppLoader
v-if="parserLoading"
:loading="parserLoading"
waiting-text=""
/>
</div>
<div v-else>
<v-expansion-panels v-model="panels" multiple>
<draggable
v-if="parsedIng.length > 0"
v-model="parsedIng"
handle=".handle"
:style="{ width: '100%' }"
ghost-class="ghost"
>
<v-expansion-panel v-for="(ing, index) in parsedIng" :key="index">
<v-expansion-panel-header class="my-0 py-0" disable-icon-rotate>
<template #default="{ open }">
<v-fade-transition>
<span v-if="!open" key="0"> {{ ing.input }} </span>
</v-fade-transition>
</template>
<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'">
{{ ing.confidence ? asPercentage(ing.confidence.average) : "" }}
</div>
</template>
</v-expansion-panel-header>
<v-expansion-panel-content class="pb-0 mb-0">
<RecipeIngredientEditor v-model="parsedIng[index].ingredient" allow-insert-ingredient @insert-ingredient="insertIngredient(index)" @delete="deleteIngredient(index)" />
{{ ing.input }}
<v-card-actions>
<v-spacer />
<BaseButton
v-if="errors[index].unitError && errors[index].unitErrorMessage !== ''"
color="warning"
small
@click="createUnit(ing.ingredient.unit, index)"
>
{{ errors[index].unitErrorMessage }}
</BaseButton>
<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>
</draggable>
</v-expansion-panels>
</div>
</v-container>
</v-container>
</template>
<script lang="ts">
import { computed, defineComponent, ref, useContext, useRoute, useRouter } from "@nuxtjs/composition-api";
import { computed, defineComponent, ref, useContext, useRoute, useRouter, watch } from "@nuxtjs/composition-api";
import { invoke, until } from "@vueuse/core";
import draggable from "vuedraggable";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import { useAppInfo, useUserApi } from "~/composables/api";
import { useRecipe } from "~/composables/recipes";
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
import { useParsingPreferences } from "~/composables/use-users/preferences";
import { uuid4 } from "~/composables/use-utils";
import {
CreateIngredientFood,
CreateIngredientUnit,
@ -108,12 +114,7 @@ import {
ParsedIngredient,
RecipeIngredient,
} from "~/lib/api/types/recipe";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import { useUserApi } from "~/composables/api";
import { useRecipe } from "~/composables/recipes";
import { useFoodData, useFoodStore, useUnitStore, useUnitData } from "~/composables/store";
import { Parser } from "~/lib/api/user/recipes/recipe";
import { uuid4 } from "~/composables/use-utils";
interface Error {
ingredientIndex: number;
@ -130,7 +131,7 @@ export default defineComponent({
},
middleware: ["auth", "group-only"],
setup() {
const { $auth } = useContext();
const { $auth, i18n } = useContext();
const panels = ref<number[]>([]);
const route = useRoute();
@ -139,10 +140,10 @@ export default defineComponent({
const router = useRouter();
const slug = route.value.params.slug;
const api = useUserApi();
const { i18n } = useContext();
const appInfo = useAppInfo();
const { recipe, loading } = useRecipe(slug);
const parserLoading = ref(false);
invoke(async () => {
await until(recipe).not.toBeNull();
@ -152,10 +153,32 @@ export default defineComponent({
const ingredients = ref<any[]>([]);
const availableParsers = computed(() => {
return [
{
"text": i18n.tc("recipe.parser.natural-language-processor"),
"value": "nlp",
},
{
"text": i18n.tc("recipe.parser.brute-parser"),
"value": "brute",
},
{
"text": i18n.tc("recipe.parser.openai-parser"),
"value": "openai",
"hide": !appInfo.value?.enableOpenai,
},
]
});
// =========================================================
// Parser Logic
const parser = ref<Parser>("nlp");
const parserPreferences = useParsingPreferences();
const parser = ref<Parser>(parserPreferences.value.parser || "nlp");
const parsedIng = ref<ParsedIngredient[]>([]);
watch(parser, (val) => {
parserPreferences.value.parser = val;
});
function processIngredientError(ing: ParsedIngredient, index: number): Error {
const unitError = !checkForUnit(ing.ingredient.unit);
@ -195,7 +218,10 @@ export default defineComponent({
return;
}
const raw = recipe.value.recipeIngredient.map((ing) => ing.note ?? "");
parserLoading.value = true;
const { data } = await api.recipes.parseIngredients(parser.value, raw);
parserLoading.value = false;
if (data) {
// When we send the recipe ingredient text to be parsed, we lose the reference to the original unparsed ingredient.
@ -343,6 +369,7 @@ export default defineComponent({
return {
parser,
availableParsers,
saveAll,
createFood,
createUnit,
@ -358,6 +385,7 @@ export default defineComponent({
parsedIng,
recipe,
loading,
parserLoading,
ingredients,
};
},

View File

@ -207,6 +207,31 @@ class AppSettings(BaseSettings):
return self.OIDC_AUTH_ENABLED and not_none and valid_group_claim
# ===============================================
# OpenAI Configuration
OPENAI_BASE_URL: str | None = None
"""The base URL for the OpenAI API. Leave this unset for most usecases"""
OPENAI_API_KEY: str | None = None
"""Your OpenAI API key. Required to enable OpenAI features"""
OPENAI_MODEL: str = "gpt-4o"
"""Which OpenAI model to send requests to. Leave this unset for most usecases"""
OPENAI_WORKERS: int = 2
"""
Number of OpenAI workers per request. Higher values may increase
processing speed, but will incur additional API costs
"""
OPENAI_SEND_DATABASE_DATA: bool = True
"""
Sending database data may increase accuracy in certain requests,
but will incur additional API costs
"""
@property
def OPENAI_ENABLED(self) -> bool:
"""Validates OpenAI settings are all set"""
return bool(self.OPENAI_API_KEY and self.OPENAI_MODEL)
# ===============================================
# Testing Config

View File

@ -33,6 +33,7 @@ class AdminAboutController(BaseAdminController):
enable_oidc=settings.OIDC_AUTH_ENABLED,
oidc_redirect=settings.OIDC_AUTO_REDIRECT,
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
enable_openai=settings.OPENAI_ENABLED,
)
@router.get("/statistics", response_model=AppStatistics)
@ -55,4 +56,5 @@ class AdminAboutController(BaseAdminController):
base_url_set=settings.BASE_URL != "http://localhost:8080",
is_up_to_date=APP_VERSION == "develop" or APP_VERSION == "nightly" or get_latest_version() == APP_VERSION,
oidc_ready=settings.OIDC_READY,
enable_openai=settings.OPENAI_ENABLED,
)

View File

@ -32,6 +32,7 @@ def get_app_info(session: Session = Depends(generate_session)):
enable_oidc=settings.OIDC_READY,
oidc_redirect=settings.OIDC_AUTO_REDIRECT,
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
enable_openai=settings.OPENAI_ENABLED,
)

View File

@ -11,11 +11,12 @@ router = APIRouter(prefix="/parser")
@controller(router)
class IngredientParserController(BaseUserController):
@router.post("/ingredients", response_model=list[ParsedIngredient])
def parse_ingredients(self, ingredients: IngredientsRequest):
async def parse_ingredients(self, ingredients: IngredientsRequest):
parser = get_parser(ingredients.parser, self.group_id, self.session)
return parser.parse(ingredients.ingredients)
return await parser.parse(ingredients.ingredients)
@router.post("/ingredient", response_model=ParsedIngredient)
def parse_ingredient(self, ingredient: IngredientRequest):
async def parse_ingredient(self, ingredient: IngredientRequest):
parser = get_parser(ingredient.parser, self.group_id, self.session)
return parser.parse([ingredient.ingredient])[0]
response = await parser.parse([ingredient.ingredient])
return response[0]

View File

@ -18,6 +18,7 @@ class AppInfo(MealieModel):
enable_oidc: bool
oidc_redirect: bool
oidc_provider_name: str
enable_openai: bool
class AppTheme(MealieModel):
@ -64,6 +65,7 @@ class CheckAppConfig(MealieModel):
email_ready: bool
ldap_ready: bool
oidc_ready: bool
enable_openai: bool
base_url_set: bool
is_up_to_date: bool

View File

@ -327,6 +327,7 @@ class ParsedIngredient(MealieModel):
class RegisteredParser(str, enum.Enum):
nlp = "nlp"
brute = "brute"
openai = "openai"
class IngredientsRequest(MealieModel):

View File

@ -0,0 +1,6 @@
from .openai import OpenAIDataInjection, OpenAIService
__all__ = [
"OpenAIDataInjection",
"OpenAIService",
]

View File

@ -0,0 +1,129 @@
import inspect
import json
import os
from pathlib import Path
from textwrap import dedent
from openai import NOT_GIVEN, AsyncOpenAI
from openai.resources.chat.completions import ChatCompletion
from pydantic import BaseModel, field_validator
from mealie.core.config import get_app_settings
from .._base_service import BaseService
class OpenAIDataInjection(BaseModel):
description: str
value: str
@field_validator("value", mode="before")
def parse_value(cls, value):
if not value:
raise ValueError("Value cannot be empty")
if isinstance(value, str):
return value
# convert Pydantic models to JSON
if isinstance(value, BaseModel):
return value.model_dump_json()
# convert Pydantic types to their JSON schema definition
if inspect.isclass(value) and issubclass(value, BaseModel):
value = value.model_json_schema()
# attempt to convert object to JSON
try:
return json.dumps(value, separators=(",", ":"))
except TypeError:
return value
class OpenAIService(BaseService):
PROMPTS_DIR = Path(os.path.dirname(os.path.abspath(__file__))) / "prompts"
def __init__(self) -> None:
settings = get_app_settings()
if not settings.OPENAI_ENABLED:
raise ValueError("OpenAI is not enabled")
self.model = settings.OPENAI_MODEL
self.workers = settings.OPENAI_WORKERS
self.send_db_data = settings.OPENAI_SEND_DATABASE_DATA
self.get_client = lambda: AsyncOpenAI(
base_url=settings.OPENAI_BASE_URL,
api_key=settings.OPENAI_API_KEY,
)
super().__init__()
@classmethod
def get_prompt(cls, name: str, data_injections: list[OpenAIDataInjection] | None = None) -> str:
"""
Load stored prompt and inject data into it.
Access prompts with dot notation.
For example, to access `prompts/recipes/parse-recipe-ingredients.txt`, use
`recipes.parse-recipe-ingredients`
"""
if not name:
raise ValueError("Prompt name cannot be empty")
tree = name.split(".")
prompt_dir = os.path.join(cls.PROMPTS_DIR, *tree[:-1], tree[-1] + ".txt")
try:
with open(prompt_dir) as f:
content = f.read()
except OSError as e:
raise OSError(f"Unable to load prompt {name}") from e
if not data_injections:
return content
content_parts = [content]
for data_injection in data_injections:
content_parts.append(
dedent(
f"""
###
{data_injection.description}
---
{data_injection.value}
"""
)
)
return "\n".join(content_parts)
async def _get_raw_response(
self, prompt: str, message: str, temperature=0.2, force_json_response=True
) -> ChatCompletion:
client = self.get_client()
return await client.chat.completions.create(
messages=[
{
"role": "system",
"content": prompt,
},
{
"role": "user",
"content": message,
},
],
model=self.model,
temperature=temperature,
response_format={"type": "json_object"} if force_json_response else NOT_GIVEN,
)
async def get_response(self, prompt: str, message: str, temperature=0.2, force_json_response=True) -> str | None:
"""Send data to OpenAI and return the response message content"""
try:
response = await self._get_raw_response(prompt, message, temperature, force_json_response)
if not response.choices:
return None
return response.choices[0].message.content
except Exception:
self.logger.exception("OpenAI Request Failed")
return None

View File

@ -0,0 +1,24 @@
You are a bot that parses user input into recipe ingredients. You will receive a list of one or more ingredients, each containing one or more of the following components:
- Food: the actual physical ingredient used in the recipe. For instance, if you receive "3 cups of onions, chopped", the food is "onions"
- Unit: the unit of measurement for this ingredient. For instance, if you receive "2 lbs chicken breast", the unit is "lbs" (short for "pounds")
- Quantity: the numerical representation of how much of this ingredient. For instance, if you receive "3 1/2 grams of minced garlic", the quantity is "3 1/2". Quantity may be represented as a whole number (integer), a float or decimal, or a fraction. You should output quantity in only whole numbers or floats, converting fractions into floats. Floats longer than 10 decimal places should be rounded to 10 decimal places.
- Note: the rest of the text that represents more detail on how to prepare the ingredient. Anything that is not one of the above should be the note. For instance, if you receive "one can of butter beans, drained" the note would be "drained". If you receive "3 cloves of garlic peeled and finely chopped", the note would be "peeled and finely chopped"
- Input: The input is simply the ingredient string you are processing as-is. It is forbidden to modify this at all, you must provide the input exactly as you received it
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
It is imperative that you do not create any data or otherwise make up any information. 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.
In addition to calculating the recipe ingredient fields, you are also responsible for including a confidence value. This value is a float between 0 - 1, where 1 is full confidence that the result is correct, and 0 is no confidence that the result is correct. If you're unable to parse anything, and you put the entire string in the notes, you should return 0 confidence. If you can easily parse the string into each component, then you should return a confidence of 1. If you have to guess which part is the unit and which part is the food, your confidence should be lower, such as 0.6. Even if there is no unit or note, if you're able to determine the food, you may use a higher confidence. If the entire ingredient consists of only a food, you can use a confidence of 1.
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.
The user message that you receive will be the list of one or more recipe ingredients for you to parse. Your response should have exactly one item for each item provided. For instance, if you receive 12 items to parse, then your response should be an array of 12 parsed items.

View File

@ -0,0 +1,161 @@
from abc import ABC, abstractmethod
from typing import TypeVar
from pydantic import UUID4, BaseModel
from rapidfuzz import fuzz, process
from sqlalchemy.orm import Session
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe_ingredient import (
CreateIngredientFood,
CreateIngredientUnit,
IngredientFood,
IngredientUnit,
ParsedIngredient,
)
from mealie.schema.response.pagination import PaginationQuery
T = TypeVar("T", bound=BaseModel)
class ABCIngredientParser(ABC):
"""
Abstract class for ingredient parsers.
"""
def __init__(self, group_id: UUID4, session: Session) -> None:
self.group_id = group_id
self.session = session
self._foods_by_alias: dict[str, IngredientFood] | None = None
self._units_by_alias: dict[str, IngredientUnit] | None = None
@property
def _repos(self) -> AllRepositories:
return get_repositories(self.session)
@property
def foods_by_alias(self) -> dict[str, IngredientFood]:
if self._foods_by_alias is None:
foods_repo = self._repos.ingredient_foods.by_group(self.group_id)
query = PaginationQuery(page=1, per_page=-1)
all_foods = foods_repo.page_all(query).items
foods_by_alias: dict[str, IngredientFood] = {}
for food in all_foods:
if food.name:
foods_by_alias[IngredientFoodModel.normalize(food.name)] = food
if food.plural_name:
foods_by_alias[IngredientFoodModel.normalize(food.plural_name)] = food
for alias in food.aliases or []:
if alias.name:
foods_by_alias[IngredientFoodModel.normalize(alias.name)] = food
self._foods_by_alias = foods_by_alias
return self._foods_by_alias
@property
def units_by_alias(self) -> dict[str, IngredientUnit]:
if self._units_by_alias is None:
units_repo = self._repos.ingredient_units.by_group(self.group_id)
query = PaginationQuery(page=1, per_page=-1)
all_units = units_repo.page_all(query).items
units_by_alias: dict[str, IngredientUnit] = {}
for unit in all_units:
if unit.name:
units_by_alias[IngredientUnitModel.normalize(unit.name)] = unit
if unit.plural_name:
units_by_alias[IngredientUnitModel.normalize(unit.plural_name)] = unit
if unit.abbreviation:
units_by_alias[IngredientUnitModel.normalize(unit.abbreviation)] = unit
if unit.plural_abbreviation:
units_by_alias[IngredientUnitModel.normalize(unit.plural_abbreviation)] = unit
for alias in unit.aliases or []:
if alias.name:
units_by_alias[IngredientUnitModel.normalize(alias.name)] = unit
self._units_by_alias = units_by_alias
return self._units_by_alias
@property
def food_fuzzy_match_threshold(self) -> int:
"""Minimum threshold to fuzzy match against a database food search"""
return 85
@property
def unit_fuzzy_match_threshold(self) -> int:
"""Minimum threshold to fuzzy match against a database unit search"""
return 70
@abstractmethod
async def parse_one(self, ingredient_string: str) -> ParsedIngredient: ...
@abstractmethod
async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: ...
@classmethod
def find_match(cls, match_value: str, *, store_map: dict[str, T], fuzzy_match_threshold: int = 0) -> T | None:
# check for literal matches
if match_value in store_map:
return store_map[match_value]
# fuzzy match against food store
fuzz_result = process.extractOne(
match_value, store_map.keys(), scorer=fuzz.ratio, score_cutoff=fuzzy_match_threshold
)
if fuzz_result is None:
return None
return store_map[fuzz_result[0]]
def find_food_match(self, food: IngredientFood | CreateIngredientFood | str) -> IngredientFood | None:
if isinstance(food, IngredientFood):
return food
food_name = food if isinstance(food, str) else food.name
match_value = IngredientFoodModel.normalize(food_name)
return self.find_match(
match_value,
store_map=self.foods_by_alias,
fuzzy_match_threshold=self.food_fuzzy_match_threshold,
)
def find_unit_match(self, unit: IngredientUnit | CreateIngredientUnit | str) -> IngredientUnit | None:
if isinstance(unit, IngredientUnit):
return unit
unit_name = unit if isinstance(unit, str) else unit.name
match_value = IngredientUnitModel.normalize(unit_name)
return self.find_match(
match_value,
store_map=self.units_by_alias,
fuzzy_match_threshold=self.unit_fuzzy_match_threshold,
)
def find_ingredient_match(self, ingredient: ParsedIngredient) -> ParsedIngredient:
if ingredient.ingredient.food and (food_match := self.find_food_match(ingredient.ingredient.food)):
ingredient.ingredient.food = food_match
if ingredient.ingredient.unit and (unit_match := self.find_unit_match(ingredient.ingredient.unit)):
ingredient.ingredient.unit = unit_match
# Parser might have wrongly split a food into a unit and food.
if isinstance(ingredient.ingredient.food, CreateIngredientFood) and isinstance(
ingredient.ingredient.unit, CreateIngredientUnit
):
if food_match := self.find_food_match(
f"{ingredient.ingredient.unit.name} {ingredient.ingredient.food.name}"
):
ingredient.ingredient.food = food_match
ingredient.ingredient.unit = None
return ingredient

View File

@ -1,173 +1,23 @@
from abc import ABC, abstractmethod
from fractions import Fraction
from typing import TypeVar
from pydantic import UUID4, BaseModel
from rapidfuzz import fuzz, process
from pydantic import UUID4
from sqlalchemy.orm import Session
from mealie.core.root_logger import get_logger
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe import RecipeIngredient
from mealie.schema.recipe.recipe_ingredient import (
MAX_INGREDIENT_DENOMINATOR,
CreateIngredientFood,
CreateIngredientUnit,
IngredientConfidence,
IngredientFood,
IngredientUnit,
ParsedIngredient,
RegisteredParser,
)
from mealie.schema.response.pagination import PaginationQuery
from . import brute, crfpp
from . import brute, crfpp, openai
from ._base import ABCIngredientParser
logger = get_logger(__name__)
T = TypeVar("T", bound=BaseModel)
class ABCIngredientParser(ABC):
"""
Abstract class for ingredient parsers.
"""
def __init__(self, group_id: UUID4, session: Session) -> None:
self.group_id = group_id
self.session = session
self._foods_by_alias: dict[str, IngredientFood] | None = None
self._units_by_alias: dict[str, IngredientUnit] | None = None
@property
def _repos(self) -> AllRepositories:
return get_repositories(self.session)
@property
def foods_by_alias(self) -> dict[str, IngredientFood]:
if self._foods_by_alias is None:
foods_repo = self._repos.ingredient_foods.by_group(self.group_id)
query = PaginationQuery(page=1, per_page=-1)
all_foods = foods_repo.page_all(query).items
foods_by_alias: dict[str, IngredientFood] = {}
for food in all_foods:
if food.name:
foods_by_alias[IngredientFoodModel.normalize(food.name)] = food
if food.plural_name:
foods_by_alias[IngredientFoodModel.normalize(food.plural_name)] = food
for alias in food.aliases or []:
if alias.name:
foods_by_alias[IngredientFoodModel.normalize(alias.name)] = food
self._foods_by_alias = foods_by_alias
return self._foods_by_alias
@property
def units_by_alias(self) -> dict[str, IngredientUnit]:
if self._units_by_alias is None:
units_repo = self._repos.ingredient_units.by_group(self.group_id)
query = PaginationQuery(page=1, per_page=-1)
all_units = units_repo.page_all(query).items
units_by_alias: dict[str, IngredientUnit] = {}
for unit in all_units:
if unit.name:
units_by_alias[IngredientUnitModel.normalize(unit.name)] = unit
if unit.plural_name:
units_by_alias[IngredientUnitModel.normalize(unit.plural_name)] = unit
if unit.abbreviation:
units_by_alias[IngredientUnitModel.normalize(unit.abbreviation)] = unit
if unit.plural_abbreviation:
units_by_alias[IngredientUnitModel.normalize(unit.plural_abbreviation)] = unit
for alias in unit.aliases or []:
if alias.name:
units_by_alias[IngredientUnitModel.normalize(alias.name)] = unit
self._units_by_alias = units_by_alias
return self._units_by_alias
@property
def food_fuzzy_match_threshold(self) -> int:
"""Minimum threshold to fuzzy match against a database food search"""
return 85
@property
def unit_fuzzy_match_threshold(self) -> int:
"""Minimum threshold to fuzzy match against a database unit search"""
return 70
@abstractmethod
def parse_one(self, ingredient_string: str) -> ParsedIngredient: ...
@abstractmethod
def parse(self, ingredients: list[str]) -> list[ParsedIngredient]: ...
@classmethod
def find_match(cls, match_value: str, *, store_map: dict[str, T], fuzzy_match_threshold: int = 0) -> T | None:
# check for literal matches
if match_value in store_map:
return store_map[match_value]
# fuzzy match against food store
fuzz_result = process.extractOne(
match_value, store_map.keys(), scorer=fuzz.ratio, score_cutoff=fuzzy_match_threshold
)
if fuzz_result is None:
return None
return store_map[fuzz_result[0]]
def find_food_match(self, food: IngredientFood | CreateIngredientFood | str) -> IngredientFood | None:
if isinstance(food, IngredientFood):
return food
food_name = food if isinstance(food, str) else food.name
match_value = IngredientFoodModel.normalize(food_name)
return self.find_match(
match_value,
store_map=self.foods_by_alias,
fuzzy_match_threshold=self.food_fuzzy_match_threshold,
)
def find_unit_match(self, unit: IngredientUnit | CreateIngredientUnit | str) -> IngredientUnit | None:
if isinstance(unit, IngredientUnit):
return unit
unit_name = unit if isinstance(unit, str) else unit.name
match_value = IngredientUnitModel.normalize(unit_name)
return self.find_match(
match_value,
store_map=self.units_by_alias,
fuzzy_match_threshold=self.unit_fuzzy_match_threshold,
)
def find_ingredient_match(self, ingredient: ParsedIngredient) -> ParsedIngredient:
if ingredient.ingredient.food and (food_match := self.find_food_match(ingredient.ingredient.food)):
ingredient.ingredient.food = food_match
if ingredient.ingredient.unit and (unit_match := self.find_unit_match(ingredient.ingredient.unit)):
ingredient.ingredient.unit = unit_match
# Parser might have wrongly split a food into a unit and food.
if isinstance(ingredient.ingredient.food, CreateIngredientFood) and isinstance(
ingredient.ingredient.unit, CreateIngredientUnit
):
if food_match := self.find_food_match(
f"{ingredient.ingredient.unit.name} {ingredient.ingredient.food.name}"
):
ingredient.ingredient.food = food_match
ingredient.ingredient.unit = None
return ingredient
class BruteForceParser(ABCIngredientParser):
@ -175,7 +25,7 @@ class BruteForceParser(ABCIngredientParser):
Brute force ingredient parser.
"""
def parse_one(self, ingredient: str) -> ParsedIngredient:
async def parse_one(self, ingredient: str) -> ParsedIngredient:
bfi = brute.parse(ingredient, self)
parsed_ingredient = ParsedIngredient(
@ -191,8 +41,8 @@ class BruteForceParser(ABCIngredientParser):
return self.find_ingredient_match(parsed_ingredient)
def parse(self, ingredients: list[str]) -> list[ParsedIngredient]:
return [self.parse_one(ingredient) for ingredient in ingredients]
async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]:
return [await self.parse_one(ingredient) for ingredient in ingredients]
class NLPParser(ABCIngredientParser):
@ -234,18 +84,19 @@ class NLPParser(ABCIngredientParser):
return self.find_ingredient_match(parsed_ingredient)
def parse(self, ingredients: list[str]) -> list[ParsedIngredient]:
async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]:
crf_models = crfpp.convert_list_to_crf_model(ingredients)
return [self._crf_to_ingredient(crf_model) for crf_model in crf_models]
def parse_one(self, ingredient: str) -> ParsedIngredient:
items = self.parse([ingredient])
async def parse_one(self, ingredient_string: str) -> ParsedIngredient:
items = await self.parse([ingredient_string])
return items[0]
__registrar = {
__registrar: dict[RegisteredParser, type[ABCIngredientParser]] = {
RegisteredParser.nlp: NLPParser,
RegisteredParser.brute: BruteForceParser,
RegisteredParser.openai: openai.OpenAIParser,
}

View File

@ -0,0 +1,5 @@
from .parser import OpenAIParser
__all__ = [
"OpenAIParser",
]

View File

@ -0,0 +1,122 @@
import asyncio
import json
from collections.abc import Awaitable
from pydantic import BaseModel
from mealie.schema.recipe.recipe_ingredient import (
CreateIngredientFood,
CreateIngredientUnit,
IngredientConfidence,
ParsedIngredient,
RecipeIngredient,
)
from mealie.services.openai import OpenAIDataInjection, OpenAIService
from .._base import ABCIngredientParser
class OpenAIIngredient(BaseModel):
"""
This class defines the JSON schema sent to OpenAI. Its schema is
injected directly into the OpenAI prompt.
"""
__doc__ = "" # we don't want to include the docstring in the JSON schema
input: str
confidence: float | None = None
quantity: float | None = 0
unit: str | None = None
food: str | None = None
note: str | None = None
class OpenAIIngredients(BaseModel):
ingredients: list[OpenAIIngredient] = []
class OpenAIParser(ABCIngredientParser):
def _convert_ingredient(self, openai_ing: OpenAIIngredient) -> ParsedIngredient:
ingredient = RecipeIngredient(
original_text=openai_ing.input,
quantity=openai_ing.quantity,
unit=CreateIngredientUnit(name=openai_ing.unit) if openai_ing.unit else None,
food=CreateIngredientFood(name=openai_ing.food) if openai_ing.food else None,
note=openai_ing.note,
)
parsed_ingredient = ParsedIngredient(
input=openai_ing.input,
confidence=IngredientConfidence(average=openai_ing.confidence),
ingredient=ingredient,
)
return self.find_ingredient_match(parsed_ingredient)
def _get_prompt(self, service: OpenAIService) -> str:
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,
),
]
if service.send_db_data:
data_injections.extend(
[
OpenAIDataInjection(
description=(
"Below is a list of units found in the units database. While parsing, you should "
"reference this list when determining which part of the input is the unit. You may "
"find a unit in the input that does not exist in this list. This should not prevent "
"you from parsing that text as a unit, however it may lower your confidence level."
),
value=list(set(self.units_by_alias)),
),
]
)
return service.get_prompt("recipes.parse-recipe-ingredients", data_injections=data_injections)
@staticmethod
def _chunk_messages(messages: list[str], n=1) -> list[list[str]]:
if n < 1:
n = 1
return [messages[i : i + n] for i in range(0, len(messages), n)]
async def _parse(self, ingredients: list[str]) -> OpenAIIngredients:
service = OpenAIService()
prompt = self._get_prompt(service)
# 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
responses_json = await asyncio.gather(*tasks)
responses = [
OpenAIIngredients.model_validate_json(response_json) for response_json in responses_json if responses_json
]
if not responses:
raise Exception("No response from OpenAI")
return OpenAIIngredients(
ingredients=[ingredient for response in responses for ingredient in response.ingredients]
)
async def parse_one(self, ingredient_string: str) -> ParsedIngredient:
items = await self.parse([ingredient_string])
return items[0]
async def parse(self, ingredients: list[str]) -> list[ParsedIngredient]:
response = await self._parse(ingredients)
return [self._convert_ingredient(ing) for ing in response.ingredients]

56
poetry.lock generated
View File

@ -566,6 +566,17 @@ files = [
{file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"},
]
[[package]]
name = "distro"
version = "1.9.0"
description = "Distro - an OS platform information API"
optional = false
python-versions = ">=3.6"
files = [
{file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"},
{file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"},
]
[[package]]
name = "dnspython"
version = "2.6.1"
@ -1491,6 +1502,29 @@ rsa = ["cryptography (>=3.0.0)"]
signals = ["blinker (>=1.4.0)"]
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]]
name = "openai"
version = "1.27.0"
description = "The official Python library for the openai API"
optional = false
python-versions = ">=3.7.1"
files = [
{file = "openai-1.27.0-py3-none-any.whl", hash = "sha256:1183346fae6e63cb3a9134e397c0067690dc9d94ceb36eb0eb2c1bb9a1542aca"},
{file = "openai-1.27.0.tar.gz", hash = "sha256:498adc80ba81a95324afdfd11a71fa43a37e1d94a5ca5f4542e52fe9568d995b"},
]
[package.dependencies]
anyio = ">=3.5.0,<5"
distro = ">=1.7.0,<2"
httpx = ">=0.23.0,<1"
pydantic = ">=1.9.0,<3"
sniffio = "*"
tqdm = ">4"
typing-extensions = ">=4.7,<5"
[package.extras]
datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"]
[[package]]
name = "orjson"
version = "3.10.3"
@ -2886,6 +2920,26 @@ files = [
{file = "tomlkit-0.11.6.tar.gz", hash = "sha256:71b952e5721688937fb02cf9d354dbcf0785066149d2855e44531ebdd2b65d73"},
]
[[package]]
name = "tqdm"
version = "4.66.4"
description = "Fast, Extensible Progress Meter"
optional = false
python-versions = ">=3.7"
files = [
{file = "tqdm-4.66.4-py3-none-any.whl", hash = "sha256:b75ca56b413b030bc3f00af51fd2c1a1a5eac6a0c1cca83cbb37a5c52abce644"},
{file = "tqdm-4.66.4.tar.gz", hash = "sha256:e4d936c9de8727928f3be6079590e97d9abfe8d39a590be678eb5919ffc186bb"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[package.extras]
dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"]
notebook = ["ipywidgets (>=6)"]
slack = ["slack-sdk"]
telegram = ["requests"]
[[package]]
name = "typer"
version = "0.12.3"
@ -3339,4 +3393,4 @@ pgsql = ["psycopg2-binary"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "e774640ffaf7f86a486066bead71af770a2a974385073fffa2c7a7333a15fc5d"
content-hash = "4155d1d818a04f1e03b512399d5ecbc01b007fae7884f6322d5d389745276a17"

View File

@ -48,6 +48,7 @@ paho-mqtt = "^1.6.1"
pydantic-settings = "^2.1.0"
pillow-heif = "^0.16.0"
pyjwt = "^2.8.0"
openai = "^1.27.0"
[tool.poetry.group.postgres.dependencies]
psycopg2-binary = { version = "^2.9.1" }

View File

@ -7,6 +7,7 @@ mp = MonkeyPatch()
mp.setenv("PRODUCTION", "True")
mp.setenv("TESTING", "True")
mp.setenv("ALLOW_SIGNUP", "True")
mp.setenv("OPENAI_API_KEY", "dummy-api-key")
from pathlib import Path
from fastapi.testclient import TestClient

View File

@ -1,3 +1,5 @@
import asyncio
import json
import shutil
from dataclasses import dataclass
from fractions import Fraction
@ -20,10 +22,11 @@ from mealie.schema.recipe.recipe_ingredient import (
SaveIngredientUnit,
)
from mealie.schema.user.user import GroupBase
from mealie.services.openai import OpenAIService
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.openai.parser import OpenAIIngredient, OpenAIIngredients
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
@dataclass
@ -223,8 +226,9 @@ def test_brute_parser(
comment: str,
):
with session_context() as session:
loop = asyncio.get_event_loop()
parser = get_parser(RegisteredParser.brute, unique_local_group_id, session)
parsed = parser.parse_one(input)
parsed = loop.run_until_complete(parser.parse_one(input))
ing = parsed.ingredient
if ing.quantity:
@ -430,3 +434,43 @@ def test_parser_ingredient_match(
assert parsed_ingredient.ingredient.unit is None or isinstance(
parsed_ingredient.ingredient.unit, CreateIngredientUnit
)
def test_openai_parser(
unique_local_group_id: UUID4,
parsed_ingredient_data: tuple[list[IngredientFood], list[IngredientUnit]], # required so database is populated
monkeypatch: pytest.MonkeyPatch,
):
ingredient_count = random_int(10, 20)
async def mock_get_response(self, prompt: str, message: str, *args, **kwargs) -> str | None:
inputs = json.loads(message)
data = OpenAIIngredients(
ingredients=[
OpenAIIngredient(
input=input,
confidence=1,
quantity=random_int(0, 10),
unit=random_string(),
food=random_string(),
note=random_string(),
)
for input in inputs
]
)
return data.model_dump_json()
monkeypatch.setattr(OpenAIService, "get_response", mock_get_response)
with session_context() as session:
loop = asyncio.get_event_loop()
parser = get_parser(RegisteredParser.openai, unique_local_group_id, session)
inputs = [random_string() for _ in range(ingredient_count)]
parsed = loop.run_until_complete(parser.parse(inputs))
# since OpenAI is mocked, we don't need to validate the data, we just need to make sure parsing works
# and that it preserves order
assert len(parsed) == ingredient_count
for input, output in zip(inputs, parsed, strict=True):
assert output.input == input