added AI step sorter

This commit is contained in:
vabene1111
2025-09-22 08:21:26 +02:00
parent 64d2108ef6
commit fa7cc12b99
8 changed files with 255 additions and 37 deletions

View File

@@ -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,
)

View File

@@ -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):

View File

@@ -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()

View File

@@ -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'),

View File

@@ -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]

View File

@@ -0,0 +1,65 @@
<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">
{{ props.text }}
<v-menu activator="parent">
<v-list>
<v-list-item
v-for="provider in aiProviders"
:key="provider.id!"
@click="emit('selected', provider.id!)"
>
<v-list-item-title>
{{ provider.name }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
</template>
<script setup lang="ts">
import {AiProvider, ApiApi} from "@/openapi";
import {onMounted, PropType, ref} from "vue";
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
const emit = defineEmits(['selected'])
const props = defineProps({
text: {type: String, default: 'AI'},
color: {type: String, default: ''},
variant: {type: undefined, default: undefined},
density: {type: undefined, default: undefined},
icon: {type: String, default: undefined},
prependIcon: {type: String, default: undefined},
loading: {type: Boolean, default: false},
})
const aiProviders = ref([] as AiProvider[])
onMounted(() => {
loadAiProviders()
})
function loadAiProviders() {
let api = new ApiApi()
api.apiAiProviderList().then(r => {
aiProviders.value = r.results
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
})
}
</script>
<style scoped>
</style>

View File

@@ -88,6 +88,7 @@
v-if="!mobile">{{ $t('Split') }}</span></v-btn>
<v-btn prepend-icon="fa-solid fa-minimize" @click="handleMergeAllSteps" :disabled="editingObj.steps.length < 2"><span
v-if="!mobile">{{ $t('Merge') }}</span></v-btn>
<ai-action-button :text="$t('Auto_Sort')" prepend-icon="$ai" :loading="aiStepSortLoading" @selected="aiStepSort"></ai-action-button>
</v-btn-group>
@@ -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<File | null>(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
})
}
</script>
<style scoped>

View File

@@ -606,6 +606,11 @@ export interface ApiAiProviderUpdateRequest {
aiProvider: Omit<AiProvider, 'createdAt'|'updatedAt'>;
}
export interface ApiAiStepSortCreateRequest {
recipe: Omit<Recipe, 'image'|'createdBy'|'createdAt'|'updatedAt'|'foodProperties'|'rating'|'lastCooked'>;
provider?: number;
}
export interface ApiAutoPlanCreateRequest {
autoMealPlan: AutoMealPlan;
}
@@ -3327,6 +3332,50 @@ export class ApiApi extends runtime.BaseAPI {
return await response.value();
}
/**
* given an image or PDF file convert its content to a structured recipe using AI and the scraping system
*/
async apiAiStepSortCreateRaw(requestParameters: ApiAiStepSortCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Recipe>> {
if (requestParameters['recipe'] == null) {
throw new runtime.RequiredError(
'recipe',
'Required parameter "recipe" was null or undefined when calling apiAiStepSortCreate().'
);
}
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/ai-step-sort/`,
method: 'POST',
headers: headerParameters,
query: queryParameters,
body: RecipeToJSON(requestParameters['recipe']),
}, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) => RecipeFromJSON(jsonValue));
}
/**
* given an image or PDF file convert its content to a structured recipe using AI and the scraping system
*/
async apiAiStepSortCreate(requestParameters: ApiAiStepSortCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Recipe> {
const response = await this.apiAiStepSortCreateRaw(requestParameters, initOverrides);
return await response.value();
}
/**
* logs request counts to redis cache total/per user/
*/