fxied recipe properties editor and added recipe AI properties

This commit is contained in:
vabene1111
2025-10-05 12:55:44 +02:00
parent ffd951a7f4
commit f7713a43a7
5 changed files with 214 additions and 11 deletions

View File

@@ -431,6 +431,7 @@ class AiLog(models.Model, PermissionModelMixin):
F_FILE_IMPORT = 'FILE_IMPORT'
F_STEP_SORT = 'STEP_SORT'
F_FOOD_PROPERTIES = 'FOOD_PROPERTIES'
F_RECIPE_PROPERTIES = 'RECIPE_PROPERTIES'
ai_provider = models.ForeignKey(AiProvider, on_delete=models.SET_NULL, null=True)
function = models.CharField(max_length=64)

View File

@@ -1805,6 +1805,82 @@ class RecipeViewSet(LoggingMixin, viewsets.ModelViewSet, DeleteRelationMixing):
return Response(serializer.errors, 400)
@extend_schema(
parameters=[
OpenApiParameter(name='provider', description='ID of the AI provider that should be used for this AI request', type=int),
]
)
@decorators.action(detail=True, methods=['POST'], )
def aiproperties(self, request, pk):
serializer = RecipeSerializer(data=request.data, partial=True, context={'request': request})
if serializer.is_valid():
if not request.query_params.get('provider', None) or not re.match(r'^(\d)+$', request.query_params.get('provider', None)):
response = {
'error': True,
'msg': _('You must select an AI provider to perform your request.'),
}
return Response(response, status=status.HTTP_400_BAD_REQUEST)
if not can_perform_ai_request(request.space):
response = {
'error': True,
'msg': _("You don't have any credits remaining to use AI or AI features are not enabled for your space."),
}
return Response(response, status=status.HTTP_400_BAD_REQUEST)
ai_provider = AiProvider.objects.filter(pk=request.query_params.get('provider')).filter(Q(space=request.space) | Q(space__isnull=True)).first()
litellm.callbacks = [AiCallbackHandler(request.space, request.user, ai_provider, AiLog.F_RECIPE_PROPERTIES)]
property_type_list = list(PropertyType.objects.filter(space=request.space).values('id', 'name', 'description', 'unit', 'category', 'fdc_id'))
messages = [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Given the following recipe and the following different types of properties please update the recipe so that the properties attribute contains a list with all property types in the following format [{property_amount: <the property value>, property_type: {id: <the ID of the property type>, name: <the name of the property type>}}]."
"The property values should be in the unit given in the property type and calculated based on the total quantity of the foods used for the recipe."
"property_amount is a decimal number. Please try to keep a precision of two decimal places if given in your source data."
"Do not make up any data. If there is no data available for the given property type that is ok, just return null as a property_amount for that property type. Do not change anything else!"
"Most property types are likely going to be nutritional values. Please do not make up any values, only return values you can find in the sources available to you."
"Under no circumstance are you allowed to change any other value of the given food or change the structure in any way or form."
},
{
"type": "text",
"text": json.dumps(request.data)
},
{
"type": "text",
"text": json.dumps(property_type_list)
},
]
},
]
try:
ai_request = {
'api_key': ai_provider.api_key,
'model': ai_provider.model_name,
'response_format': {"type": "json_object"},
'messages': messages,
}
if ai_provider.url:
ai_request['api_base'] = ai_provider.url
ai_response = completion(**ai_request)
response_text = ai_response.choices[0].message.content
return Response(json.loads(response_text), status=status.HTTP_200_OK)
except BadRequestError as err:
pass
response = {
'error': True,
'msg': 'The AI could not process your request. \n\n' + err.message,
}
return Response(response, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(responses=RecipeSerializer(many=False))
@decorators.action(detail=True, pagination_class=None, methods=['PATCH'], serializer_class=RecipeSerializer)
def delete_external(self, request, pk):

View File

@@ -2,7 +2,7 @@
<v-btn-group density="compact">
<v-btn color="create" @click="editingObj.properties.push({} as Property)" prepend-icon="$create">{{ $t('Add') }}</v-btn>
<v-btn color="secondary" @click="addAllProperties" prepend-icon="fa-solid fa-list">{{ $t('AddAll') }}</v-btn>
<ai-action-button color="info" @selected="propertiesFromAi" :loading="aiLoading" prepend-icon="$ai" v-if="isFood">{{ $t('AI') }}</ai-action-button>
<ai-action-button color="info" @selected="propertiesFromAi" :loading="aiLoading" prepend-icon="$ai">{{ $t('AI') }}</ai-action-button>
</v-btn-group>
<v-row class="d-none d-md-flex mt-2" v-for="p in editingObj.properties" dense>
@@ -56,7 +56,7 @@ const isFood = computed(() => {
return !('steps' in editingObj.value)
})
const editingObj = defineModel<Food|Recipe>({required: true})
const editingObj = defineModel<Food | Recipe>({required: true})
const aiLoading = ref(false)
@@ -94,13 +94,25 @@ function addAllProperties() {
function propertiesFromAi(providerId: number) {
const api = new ApiApi()
aiLoading.value = true
api.apiFoodAipropertiesCreate({id: editingObj.value.id!, food: editingObj.value, provider: providerId}).then(r => {
editingObj.value = r
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
}).finally(() => {
aiLoading.value = false
})
if (isFood.value) {
api.apiFoodAipropertiesCreate({id: editingObj.value.id!, food: editingObj.value, provider: providerId}).then(r => {
editingObj.value = r
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
}).finally(() => {
aiLoading.value = false
})
} else {
api.apiRecipeAipropertiesCreate({id: editingObj.value.id!, recipe: editingObj.value, provider: providerId}).then(r => {
editingObj.value = r
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
}).finally(() => {
aiLoading.value = false
})
}
}

View File

@@ -15,8 +15,8 @@
<v-tabs v-model="tab" :disabled="loading || fileApiLoading" grow>
<v-tab value="recipe">{{ $t('Recipe') }}</v-tab>
<v-tab value="steps">{{ $t('Steps') }}</v-tab>
<v-tab value="properties">{{ $t('Properties') }}</v-tab>
<v-tab value="settings">{{ $t('Miscellaneous') }}</v-tab>
<v-tab value="properties" :disabled="!isUpdate()">{{ $t('Properties') }}</v-tab>
<v-tab value="settings" :disabled="!isUpdate()">{{ $t('Miscellaneous') }}</v-tab>
</v-tabs>
</v-card-text>
<v-card-text v-if="!isSpaceAtRecipeLimit(useUserPreferenceStore().activeSpace)">

View File

@@ -877,6 +877,12 @@ export interface ApiEnterpriseSocialKeywordUpdateRequest {
keyword: Omit<Keyword, 'label'|'parent'|'numchild'|'createdAt'|'updatedAt'|'fullName'>;
}
export interface ApiEnterpriseSocialRecipeAipropertiesCreateRequest {
id: number;
recipe: Omit<Recipe, 'image'|'createdBy'|'createdAt'|'updatedAt'|'foodProperties'|'rating'|'lastCooked'>;
provider?: number;
}
export interface ApiEnterpriseSocialRecipeBatchUpdateUpdateRequest {
recipeBatchUpdate: RecipeBatchUpdate;
}
@@ -1689,6 +1695,12 @@ export interface ApiPropertyUpdateRequest {
property: Property;
}
export interface ApiRecipeAipropertiesCreateRequest {
id: number;
recipe: Omit<Recipe, 'image'|'createdBy'|'createdAt'|'updatedAt'|'foodProperties'|'rating'|'lastCooked'>;
provider?: number;
}
export interface ApiRecipeBatchUpdateUpdateRequest {
recipeBatchUpdate: RecipeBatchUpdate;
}
@@ -5574,6 +5586,57 @@ export class ApiApi extends runtime.BaseAPI {
return await response.value();
}
/**
* logs request counts to redis cache total/per user/
*/
async apiEnterpriseSocialRecipeAipropertiesCreateRaw(requestParameters: ApiEnterpriseSocialRecipeAipropertiesCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Recipe>> {
if (requestParameters['id'] == null) {
throw new runtime.RequiredError(
'id',
'Required parameter "id" was null or undefined when calling apiEnterpriseSocialRecipeAipropertiesCreate().'
);
}
if (requestParameters['recipe'] == null) {
throw new runtime.RequiredError(
'recipe',
'Required parameter "recipe" was null or undefined when calling apiEnterpriseSocialRecipeAipropertiesCreate().'
);
}
const queryParameters: any = {};
if (requestParameters['provider'] != null) {
queryParameters['provider'] = requestParameters['provider'];
}
const headerParameters: runtime.HTTPHeaders = {};
headerParameters['Content-Type'] = 'application/json';
if (this.configuration && this.configuration.apiKey) {
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
}
const response = await this.request({
path: `/api/enterprise-social-recipe/{id}/aiproperties/`.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters['id']))),
method: 'POST',
headers: headerParameters,
query: queryParameters,
body: RecipeToJSON(requestParameters['recipe']),
}, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) => RecipeFromJSON(jsonValue));
}
/**
* logs request counts to redis cache total/per user/
*/
async apiEnterpriseSocialRecipeAipropertiesCreate(requestParameters: ApiEnterpriseSocialRecipeAipropertiesCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Recipe> {
const response = await this.apiEnterpriseSocialRecipeAipropertiesCreateRaw(requestParameters, initOverrides);
return await response.value();
}
/**
* logs request counts to redis cache total/per user/
*/
@@ -12351,6 +12414,57 @@ export class ApiApi extends runtime.BaseAPI {
return await response.value();
}
/**
* logs request counts to redis cache total/per user/
*/
async apiRecipeAipropertiesCreateRaw(requestParameters: ApiRecipeAipropertiesCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Recipe>> {
if (requestParameters['id'] == null) {
throw new runtime.RequiredError(
'id',
'Required parameter "id" was null or undefined when calling apiRecipeAipropertiesCreate().'
);
}
if (requestParameters['recipe'] == null) {
throw new runtime.RequiredError(
'recipe',
'Required parameter "recipe" was null or undefined when calling apiRecipeAipropertiesCreate().'
);
}
const queryParameters: any = {};
if (requestParameters['provider'] != null) {
queryParameters['provider'] = requestParameters['provider'];
}
const headerParameters: runtime.HTTPHeaders = {};
headerParameters['Content-Type'] = 'application/json';
if (this.configuration && this.configuration.apiKey) {
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
}
const response = await this.request({
path: `/api/recipe/{id}/aiproperties/`.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters['id']))),
method: 'POST',
headers: headerParameters,
query: queryParameters,
body: RecipeToJSON(requestParameters['recipe']),
}, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) => RecipeFromJSON(jsonValue));
}
/**
* logs request counts to redis cache total/per user/
*/
async apiRecipeAipropertiesCreate(requestParameters: ApiRecipeAipropertiesCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Recipe> {
const response = await this.apiRecipeAipropertiesCreateRaw(requestParameters, initOverrides);
return await response.value();
}
/**
* logs request counts to redis cache total/per user/
*/