diff --git a/cookbook/helper/ai_helper.py b/cookbook/helper/ai_helper.py index 4c5c19b13..c9d06f800 100644 --- a/cookbook/helper/ai_helper.py +++ b/cookbook/helper/ai_helper.py @@ -33,12 +33,14 @@ class AiCallbackHandler(CustomLogger): space = None user = None ai_provider = None + function = None - def __init__(self, space, user, ai_provider): + def __init__(self, space, user, ai_provider, function): super().__init__() self.space = space self.user = user self.ai_provider = ai_provider + self.function = function def log_pre_api_call(self, model, messages, kwargs): pass @@ -77,7 +79,7 @@ class AiCallbackHandler(CustomLogger): end_time=end_time, input_tokens=response_obj['usage']['prompt_tokens'], output_tokens=response_obj['usage']['completion_tokens'], - function=AiLog.F_FILE_IMPORT, + function=self.function, credit_cost=credit_cost, credits_from_balance=credits_from_balance, ) diff --git a/cookbook/models.py b/cookbook/models.py index 103b3a3dc..e89398d71 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -426,6 +426,7 @@ class AiProvider(models.Model): class AiLog(models.Model, PermissionModelMixin): F_FILE_IMPORT = 'FILE_IMPORT' + F_STEP_SORT = 'STEP_SORT' ai_provider = models.ForeignKey(AiProvider, on_delete=models.SET_NULL, null=True) function = models.CharField(max_length=64) @@ -447,7 +448,7 @@ class AiLog(models.Model, PermissionModelMixin): return f"{self.function} {self.ai_provider.name} {self.created_at}" class Meta: - ordering = ('id',) + ordering = ('-created_at',) class ConnectorConfig(models.Model, PermissionModelMixin): diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 13568de47..e1d6bab36 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -151,19 +151,22 @@ class CustomOnHandField(serializers.Field): return instance def to_representation(self, obj): - if not self.context["request"].user.is_authenticated: + try: + if not self.context["request"].user.is_authenticated: + return [] + shared_users = [] + if c := caches['default'].get(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None): + shared_users = c + else: + try: + shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id] + caches['default'].set(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', shared_users, timeout=5 * 60) + # TODO ugly hack that improves API performance significantly, should be done properly + except AttributeError: # Anonymous users (using share links) don't have shared users + pass + return obj.onhand_users.filter(id__in=shared_users).exists() + except AttributeError: return [] - shared_users = [] - if c := caches['default'].get(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None): - shared_users = c - else: - try: - shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id] - caches['default'].set(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', shared_users, timeout=5 * 60) - # TODO ugly hack that improves API performance significantly, should be done properly - except AttributeError: # Anonymous users (using share links) don't have shared users - pass - return obj.onhand_users.filter(id__in=shared_users).exists() def to_internal_value(self, data): return data @@ -843,28 +846,31 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR @extend_schema_field(bool) def get_substitute_onhand(self, obj): - if not self.context["request"].user.is_authenticated: + try: + if not self.context["request"].user.is_authenticated: + return [] + shared_users = [] + if c := caches['default'].get( + f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None): + shared_users = c + else: + try: + shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [ + self.context['request'].user.id] + caches['default'].set( + f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', + shared_users, timeout=5 * 60) + # TODO ugly hack that improves API performance significantly, should be done properly + except AttributeError: # Anonymous users (using share links) don't have shared users + pass + filter = Q(id__in=obj.substitute.all()) + if obj.substitute_siblings: + filter |= Q(path__startswith=obj.path[:Food.steplen * (obj.depth - 1)], depth=obj.depth) + if obj.substitute_children: + filter |= Q(path__startswith=obj.path, depth__gt=obj.depth) + return Food.objects.filter(filter).filter(onhand_users__id__in=shared_users).exists() + except AttributeError: return [] - shared_users = [] - if c := caches['default'].get( - f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None): - shared_users = c - else: - try: - shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [ - self.context['request'].user.id] - caches['default'].set( - f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', - shared_users, timeout=5 * 60) - # TODO ugly hack that improves API performance significantly, should be done properly - except AttributeError: # Anonymous users (using share links) don't have shared users - pass - filter = Q(id__in=obj.substitute.all()) - if obj.substitute_siblings: - filter |= Q(path__startswith=obj.path[:Food.steplen * (obj.depth - 1)], depth=obj.depth) - if obj.substitute_children: - filter |= Q(path__startswith=obj.path, depth__gt=obj.depth) - return Food.objects.filter(filter).filter(onhand_users__id__in=shared_users).exists() def create(self, validated_data): name = validated_data['name'].strip() diff --git a/cookbook/urls.py b/cookbook/urls.py index bdd28c3c1..c247b224f 100644 --- a/cookbook/urls.py +++ b/cookbook/urls.py @@ -104,6 +104,7 @@ urlpatterns = [ path('api/sync_all/', api.sync_all, name='api_sync'), path('api/recipe-from-source/', api.RecipeUrlImportView.as_view(), name='api_recipe_from_source'), path('api/ai-import/', api.AiImportView.as_view(), name='api_ai_import'), + path('api/ai-step-sort/', api.AiStepSortView.as_view(), name='api_ai_step_sort'), path('api/import-open-data/', api.ImportOpenData.as_view(), name='api_import_open_data'), path('api/ingredient-from-string/', api.ingredient_from_string, name='api_ingredient_from_string'), path('api/fdc-search/', api.FdcSearchView.as_view(), name='api_fdc_search'), diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 77527cca3..b7162e72b 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -2296,7 +2296,7 @@ class AiImportView(APIView): ai_provider = AiProvider.objects.filter(pk=serializer.validated_data['ai_provider_id']).filter(Q(space=request.space) | Q(space__isnull=True)).first() - litellm.callbacks = [AiCallbackHandler(request.space, request.user, ai_provider)] + litellm.callbacks = [AiCallbackHandler(request.space, request.user, ai_provider, AiLog.F_FILE_IMPORT)] messages = [] uploaded_file = serializer.validated_data['file'] @@ -2413,6 +2413,80 @@ class AiImportView(APIView): return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_400_BAD_REQUEST) +class AiStepSortView(APIView): + throttle_classes = [AiEndpointThrottle] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] + + @extend_schema(request=RecipeSerializer(many=False), responses=RecipeSerializer(many=False), + parameters=[ + OpenApiParameter(name='provider', description='ID of the AI provider that should be used for this AI request', type=int), + ]) + def post(self, request, *args, **kwargs): + """ + given an image or PDF file convert its content to a structured recipe using AI and the scraping system + """ + 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(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(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) + + 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_STEP_SORT)] + + messages = [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "You are given data for a recipe formatted as json. You cannot under any circumstance change the value of any of the fields. You are only allowed to split the instructions into multiple steps and to sort the ingredients to their appropriate step. Your goal is to properly structure the recipe by splitting large instructions into multiple coherent steps and putting the ingredients that belong to this step into the ingredients list. Generally an ingredient of a cooking recipe should occur in the first step where its needed. Please sort the ingredients to the appropriate steps without changing any of the actual field values. Return the recipe in the same format you were given as json. Do not change any field value like strings or numbers, or change the sorting, also do not change the language." + + }, + { + "type": "text", + "text": json.dumps(request.data) + }, + + ] + }, + ] + + 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 + # TODO validate by loading/dumping using serializer ? + + return Response(json.loads(response_text), status=status.HTTP_200_OK) + except BadRequestError as err: + response = { + 'error': True, + 'msg': 'The AI could not process your request. \n\n' + err.message, + } + return Response(response, status=status.HTTP_400_BAD_REQUEST) + + class AppImportView(APIView): parser_classes = [MultiPartParser] throttle_classes = [RecipeImportThrottle] diff --git a/vue3/src/components/buttons/AiActionButton.vue b/vue3/src/components/buttons/AiActionButton.vue new file mode 100644 index 000000000..c707caab7 --- /dev/null +++ b/vue3/src/components/buttons/AiActionButton.vue @@ -0,0 +1,65 @@ + + + + + + \ No newline at end of file diff --git a/vue3/src/components/model_editors/RecipeEditor.vue b/vue3/src/components/model_editors/RecipeEditor.vue index 9b307f934..6e71650b4 100644 --- a/vue3/src/components/model_editors/RecipeEditor.vue +++ b/vue3/src/components/model_editors/RecipeEditor.vue @@ -88,6 +88,7 @@ v-if="!mobile">{{ $t('Split') }} {{ $t('Merge') }} + @@ -173,6 +174,7 @@ import {useUserPreferenceStore} from "@/stores/UserPreferenceStore"; import {mergeAllSteps, splitAllSteps} from "@/utils/step_utils.ts"; import DeleteConfirmDialog from "@/components/dialogs/DeleteConfirmDialog.vue"; import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts"; +import AiActionButton from "@/components/buttons/AiActionButton.vue"; const props = defineProps({ @@ -202,6 +204,8 @@ const dialogStepManager = ref(false) const {fileApiLoading, updateRecipeImage} = useFileApi() const file = shallowRef(null) +const aiStepSortLoading = ref(false) + onMounted(() => { initializeEditor() }) @@ -297,6 +301,22 @@ function deleteExternalFile() { }) } +/** + * sort steps and ingredients using UI and update recipe with result + * @param providerId provider to use for request + */ +function aiStepSort(providerId: number){ + let api = new ApiApi() + aiStepSortLoading.value = true + api.apiAiStepSortCreate({recipe: editingObj.value, provider: providerId}).then(r => { + editingObj.value = r + }).catch(err => { + useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err) + }).finally(() => { + aiStepSortLoading.value = false + }) +} +