mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
feat: OpenAI Ingredient Parsing (#3581)
This commit is contained in:
parent
4c8bbdcde2
commit
5c57b3dd1a
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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({
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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,
|
||||
};
|
||||
},
|
||||
|
@ -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
|
||||
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
@ -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]
|
||||
|
@ -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
|
||||
|
||||
|
@ -327,6 +327,7 @@ class ParsedIngredient(MealieModel):
|
||||
class RegisteredParser(str, enum.Enum):
|
||||
nlp = "nlp"
|
||||
brute = "brute"
|
||||
openai = "openai"
|
||||
|
||||
|
||||
class IngredientsRequest(MealieModel):
|
||||
|
6
mealie/services/openai/__init__.py
Normal file
6
mealie/services/openai/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
from .openai import OpenAIDataInjection, OpenAIService
|
||||
|
||||
__all__ = [
|
||||
"OpenAIDataInjection",
|
||||
"OpenAIService",
|
||||
]
|
129
mealie/services/openai/openai.py
Normal file
129
mealie/services/openai/openai.py
Normal 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
|
@ -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.
|
161
mealie/services/parser_services/_base.py
Normal file
161
mealie/services/parser_services/_base.py
Normal 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
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
5
mealie/services/parser_services/openai/__init__.py
Normal file
5
mealie/services/parser_services/openai/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from .parser import OpenAIParser
|
||||
|
||||
__all__ = [
|
||||
"OpenAIParser",
|
||||
]
|
122
mealie/services/parser_services/openai/parser.py
Normal file
122
mealie/services/parser_services/openai/parser.py
Normal 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
56
poetry.lock
generated
@ -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"
|
||||
|
@ -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" }
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user