added AI properties import

This commit is contained in:
vabene1111
2025-09-22 21:59:34 +02:00
parent ebee1ccd4b
commit a81bc335cc
6 changed files with 186 additions and 21 deletions

View File

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

View File

@@ -1088,6 +1088,82 @@ class FoodViewSet(LoggingMixin, TreeMixin, DeleteRelationMixing):
return JsonResponse({'msg': 'there was an error parsing the FDC data, please check the server logs'},
status=500, json_dumps_params={'indent': 4})
@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_FOOD_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 food and the following different types of properties please update the food 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 for the amount specified in the properties_food_amount attribute of the food, which is given in the properties_food_unit."
"property_amount is a decimal number. Please try to keep a percision 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."
"Only return values if you are sure they are meant for the food given. 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)
def destroy(self, *args, **kwargs):
try:
return (super().destroy(self, *args, **kwargs))
@@ -2433,14 +2509,14 @@ class AiStepSortView(APIView):
'error': True,
'msg': _('You must select an AI provider to perform your request.'),
}
return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_400_BAD_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(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_400_BAD_REQUEST)
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()

View File

@@ -1,10 +1,15 @@
<template>
<v-btn v-bind="props" :color="props.color" :variant="props.variant" :density="props.density" :icon="props.icon" :prepend-icon="props.prependIcon" :loading="props.loading">
<v-btn v-bind="props"
:color="props.color"
:variant="props.variant"
:density="props.density"
:icon="props.icon"
:prepend-icon="props.prependIcon"
:loading="props.loading"
v-if="useUserPreferenceStore().activeSpace.aiEnabled">
{{ props.text }}
<v-menu activator="parent">
<v-list>
<v-list-item
v-for="provider in aiProviders"
@@ -27,6 +32,7 @@
import {AiProvider, ApiApi} from "@/openapi";
import {onMounted, PropType, ref} from "vue";
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
const emit = defineEmits(['selected'])

View File

@@ -1,10 +1,11 @@
<template>
<v-btn-group density="compact">
<v-btn color="create" @click="properties.push({} as Property)" prepend-icon="$create">{{ $t('Add') }}</v-btn>
<v-btn color="create" @click="food.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">{{ $t('AI') }}</ai-action-button>
</v-btn-group>
<v-row class="d-none d-md-flex mt-2" v-for="p in properties" dense>
<v-row class="d-none d-md-flex mt-2" v-for="p in food.properties" dense>
<v-col cols="0" md="6">
<v-number-input :step="10" v-model="p.propertyAmount" control-variant="stacked" :precision="2">
<template #append-inner v-if="p.propertyType">
@@ -24,7 +25,7 @@
</v-col>
</v-row>
<v-list class="d-md-none">
<v-list-item v-for="p in properties" border>
<v-list-item v-for="p in food.properties" border>
<span v-if="p.propertyType">{{ p.propertyAmount }} {{ p.propertyType.unit }} {{ p.propertyType.name }} / {{ props.amountFor }}
</span>
<span v-else><i><{{ $t('New') }}></i></span>
@@ -40,24 +41,31 @@
<script setup lang="ts">
import {ApiApi, Property} from "@/openapi";
import {ApiApi, Food, Property} from "@/openapi";
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
import ModelSelect from "@/components/inputs/ModelSelect.vue";
import {PropType, ref} from "vue";
import AiActionButton from "@/components/buttons/AiActionButton.vue";
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
const props = defineProps({
amountFor: {type: String, required: true}
amountFor: {type: String, required: true},
})
const properties = defineModel<Property[]>({required: true})
const food = defineModel<Food>({required: true})
const aiLoading = ref(false)
/**
* remove a property from the list
* @param property property to delete
*/
function deleteProperty(property: Property) {
properties.value = properties.value.filter(p => p !== property)
if (food.value.properties) {
food.value.properties = food.value.properties.filter(p => p !== property)
// TODO delete from DB, needs endpoint for property relation to either recipe or food
}
}
/**
* load list of property types from server and add all types that are not yet
@@ -65,15 +73,32 @@ function deleteProperty(property: Property) {
*/
function addAllProperties() {
const api = new ApiApi()
if (food.value.properties) {
food.value.properties = []
}
api.apiPropertyTypeList().then(r => {
r.results.forEach(pt => {
if (properties.value.findIndex(x => x.propertyType.name == pt.name) == -1) {
properties.value.push({propertyAmount: 0, propertyType: pt} as Property)
if (food.value.properties.findIndex(x => x.propertyType.name == pt.name) == -1) {
food.value.properties.push({propertyAmount: 0, propertyType: pt} as Property)
}
})
})
}
function propertiesFromAi(providerId: number) {
const api = new ApiApi()
aiLoading.value = true
api.apiFoodAipropertiesCreate({id: food.value.id!, food: food.value, provider: providerId}).then(r => {
food.value = r
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
}).finally(() => {
aiLoading.value = false
})
}
</script>

View File

@@ -14,10 +14,10 @@
<v-card-text class="pa-0">
<v-tabs v-model="tab" :disabled="loading" grow>
<v-tab value="food">{{ $t('Food') }}</v-tab>
<v-tab value="properties">{{ $t('Properties') }}</v-tab>
<v-tab value="conversions">{{ $t('Conversion') }}</v-tab>
<v-tab value="hierarchy">{{ $t('Hierarchy') }}</v-tab>
<v-tab value="misc">{{ $t('Miscellaneous') }}</v-tab>
<v-tab value="properties" :disabled="!isUpdate()">{{ $t('Properties') }}</v-tab>
<v-tab value="conversions" :disabled="!isUpdate()">{{ $t('Conversion') }}</v-tab>
<v-tab value="hierarchy" :disabled="!isUpdate()">{{ $t('Hierarchy') }}</v-tab>
<v-tab value="misc" :disabled="!isUpdate()">{{ $t('Miscellaneous') }}</v-tab>
</v-tabs>
</v-card-text>
@@ -52,7 +52,7 @@
<v-number-input :label="$t('Properties_Food_Amount')" v-model="editingObj.propertiesFoodAmount" :precision="2"></v-number-input>
<model-select :label="$t('Properties_Food_Unit')" v-model="editingObj.propertiesFoodUnit" model="Unit"></model-select>
<properties-editor v-model="editingObj.properties" :amount-for="propertiesAmountFor"></properties-editor>
<properties-editor v-model="editingObj" :amount-for="propertiesAmountFor"></properties-editor>
<!-- TODO remove once append to body for model select is working properly -->
<v-spacer style="margin-top: 80px;"></v-spacer>

View File

@@ -1057,6 +1057,12 @@ export interface ApiFdcSearchRetrieveRequest {
query?: string;
}
export interface ApiFoodAipropertiesCreateRequest {
id: number;
food: Omit<Food, 'shopping'|'parent'|'numchild'|'fullName'|'substituteOnhand'>;
provider?: number;
}
export interface ApiFoodBatchUpdateUpdateRequest {
foodBatchUpdate: FoodBatchUpdate;
}
@@ -6953,6 +6959,57 @@ export class ApiApi extends runtime.BaseAPI {
return await response.value();
}
/**
* logs request counts to redis cache total/per user/
*/
async apiFoodAipropertiesCreateRaw(requestParameters: ApiFoodAipropertiesCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Food>> {
if (requestParameters['id'] == null) {
throw new runtime.RequiredError(
'id',
'Required parameter "id" was null or undefined when calling apiFoodAipropertiesCreate().'
);
}
if (requestParameters['food'] == null) {
throw new runtime.RequiredError(
'food',
'Required parameter "food" was null or undefined when calling apiFoodAipropertiesCreate().'
);
}
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/food/{id}/aiproperties/`.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters['id']))),
method: 'POST',
headers: headerParameters,
query: queryParameters,
body: FoodToJSON(requestParameters['food']),
}, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) => FoodFromJSON(jsonValue));
}
/**
* logs request counts to redis cache total/per user/
*/
async apiFoodAipropertiesCreate(requestParameters: ApiFoodAipropertiesCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Food> {
const response = await this.apiFoodAipropertiesCreateRaw(requestParameters, initOverrides);
return await response.value();
}
/**
* logs request counts to redis cache total/per user/
*/