This commit is contained in:
@@ -6,11 +6,12 @@ import { AiService } from './providers/ai.service';
|
|||||||
import { AiChatRepository } from './repositories/ai-chat.repository';
|
import { AiChatRepository } from './repositories/ai-chat.repository';
|
||||||
import { AiChat } from './entities/ai-chat.entity';
|
import { AiChat } from './entities/ai-chat.entity';
|
||||||
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
||||||
|
import { AiCrone } from './crone/ai.crone';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MikroOrmModule.forFeature([AiChat]), JwtModule, RestaurantsModule],
|
imports: [MikroOrmModule.forFeature([AiChat]), JwtModule, RestaurantsModule],
|
||||||
controllers: [AiController],
|
controllers: [AiController],
|
||||||
providers: [AiService, AiChatRepository],
|
providers: [AiService, AiChatRepository, AiCrone],
|
||||||
exports: [AiChatRepository],
|
exports: [AiChatRepository],
|
||||||
})
|
})
|
||||||
export class AiModule {}
|
export class AiModule {}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiBody, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||||
import { AiService } from '../providers/ai.service';
|
import { AiService } from '../providers/ai.service';
|
||||||
import { ChatDto } from '../dto/chat.dto';
|
import { ChatDto } from '../dto/chat.dto';
|
||||||
|
import { FindAiChatsDto } from '../dto/find-ai-chats.dto';
|
||||||
import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto';
|
import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto';
|
||||||
import { GenerateFoodContentDto } from '../dto/generate-food-content.dto';
|
import { GenerateFoodContentDto } from '../dto/generate-food-content.dto';
|
||||||
import { OptionalAuthGuard } from '../../auth/guards/optinalAuth.guard';
|
import { OptionalAuthGuard } from '../../auth/guards/optinalAuth.guard';
|
||||||
@@ -42,6 +43,20 @@ export class AiController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.UPDATE_RESTAURANT)
|
||||||
|
@Get('admin/ai/chats')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get paginated list of AI chats for the restaurant' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'search', required: false, type: String })
|
||||||
|
@ApiQuery({ name: 'orderBy', required: false, type: String })
|
||||||
|
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
|
||||||
|
async findAllPaginated(@RestId() restId: string, @Query() dto: FindAiChatsDto) {
|
||||||
|
return this.aiService.findAllPaginated(restId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.MANAGE_FOODS)
|
@Permissions(Permission.MANAGE_FOODS)
|
||||||
@Post('admin/ai/food-description')
|
@Post('admin/ai/food-description')
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { CreateRequestContext } from '@mikro-orm/core';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
import { AiChat } from '../entities/ai-chat.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiCrone {
|
||||||
|
private readonly logger = new Logger(AiCrone.name);
|
||||||
|
|
||||||
|
constructor(private readonly em: EntityManager) {}
|
||||||
|
|
||||||
|
// run every day at 03:00 (3:00 AM)
|
||||||
|
@Cron('0 3 * * *', {
|
||||||
|
name: 'deleteOldAiChats',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
})
|
||||||
|
@CreateRequestContext()
|
||||||
|
async handleCron() {
|
||||||
|
try {
|
||||||
|
this.logger.debug('Starting daily AI chat cleanup (removing questions older than 1 month)');
|
||||||
|
|
||||||
|
const oneMonthAgo = new Date();
|
||||||
|
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||||
|
|
||||||
|
const oldAiChats = await this.em.find(AiChat, {
|
||||||
|
createdAt: { $lt: oneMonthAgo },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!oldAiChats || oldAiChats.length === 0) {
|
||||||
|
this.logger.debug('No old AI chats found to delete');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Found ${oldAiChats.length} AI chats older than 1 month to delete`);
|
||||||
|
|
||||||
|
await this.em.removeAndFlush(oldAiChats);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully deleted ${oldAiChats.length} old AI chats`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`AiCrone failed: ${err?.message}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { IsOptional, IsNumber, IsString, IsIn, Min } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
const sortOrderOptions = ['asc', 'desc'] as const;
|
||||||
|
type SortOrder = (typeof sortOrderOptions)[number];
|
||||||
|
|
||||||
|
export class FindAiChatsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ example: 1, description: 'Page number', minimum: 1, default: 1 })
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ example: 10, description: 'Items per page', minimum: 1, default: 10 })
|
||||||
|
limit?: number = 10;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({ example: 'گیاهخوار', description: 'Search in question text' })
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({ example: 'createdAt', description: 'Field to sort by', default: 'createdAt' })
|
||||||
|
orderBy?: string = 'createdAt';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(sortOrderOptions)
|
||||||
|
@ApiPropertyOptional({ example: 'desc', enum: sortOrderOptions, default: 'desc' })
|
||||||
|
order?: SortOrder = 'desc';
|
||||||
|
}
|
||||||
@@ -11,9 +11,11 @@ import { EntityManager } from '@mikro-orm/postgresql';
|
|||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
import { catchError, firstValueFrom, throwError } from 'rxjs';
|
import { catchError, firstValueFrom, throwError } from 'rxjs';
|
||||||
import { ChatDto } from '../dto/chat.dto';
|
import { ChatDto } from '../dto/chat.dto';
|
||||||
|
import { FindAiChatsDto } from '../dto/find-ai-chats.dto';
|
||||||
import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto';
|
import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto';
|
||||||
import { GenerateFoodContentDto } from '../dto/generate-food-content.dto';
|
import { GenerateFoodContentDto } from '../dto/generate-food-content.dto';
|
||||||
import { AiChat } from '../entities/ai-chat.entity';
|
import { AiChat } from '../entities/ai-chat.entity';
|
||||||
|
import { AiChatRepository } from '../repositories/ai-chat.repository';
|
||||||
import { AiChatCompletionResponse, AiChatResult } from '../interface/ai-chat.interface';
|
import { AiChatCompletionResponse, AiChatResult } from '../interface/ai-chat.interface';
|
||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { User } from '../../users/entities/user.entity';
|
||||||
@@ -24,6 +26,7 @@ import {
|
|||||||
RestaurantCreditTransactionType,
|
RestaurantCreditTransactionType,
|
||||||
} from '../../restaurants/interface/restaurant-credit-transaction.interface';
|
} from '../../restaurants/interface/restaurant-credit-transaction.interface';
|
||||||
import { AiMessage, RestMessage } from 'src/common/enums/message.enum';
|
import { AiMessage, RestMessage } from 'src/common/enums/message.enum';
|
||||||
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiService {
|
export class AiService {
|
||||||
@@ -34,8 +37,13 @@ export class AiService {
|
|||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly restaurantWalletService: RestaurantWalletService,
|
private readonly restaurantWalletService: RestaurantWalletService,
|
||||||
|
private readonly aiChatRepository: AiChatRepository,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
findAllPaginated(restId: string, dto: FindAiChatsDto): Promise<PaginatedResult<AiChat>> {
|
||||||
|
return this.aiChatRepository.findAllPaginated(restId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
async chat(dto: ChatDto, params: { restId?: string; userId?: string; slug?: string }): Promise<AiChatResult> {
|
async chat(dto: ChatDto, params: { restId?: string; userId?: string; slug?: string }): Promise<AiChatResult> {
|
||||||
const restaurant = await this.resolveRestaurant(params.restId, params.slug);
|
const restaurant = await this.resolveRestaurant(params.restId, params.slug);
|
||||||
const user = params.userId ? await this.em.findOne(User, { id: params.userId }) : null;
|
const user = params.userId ? await this.em.findOne(User, { id: params.userId }) : null;
|
||||||
@@ -82,7 +90,7 @@ export class AiService {
|
|||||||
'لحن باید جذاب، مختصر و مناسب منوی دیجیتال باشد.',
|
'لحن باید جذاب، مختصر و مناسب منوی دیجیتال باشد.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
userPrompt: `نام غذا: ${dto.foodName}`,
|
userPrompt: `نام غذا: ${dto.foodName}`,
|
||||||
maxTokens: 150,
|
maxTokens: 20000,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +113,7 @@ export class AiService {
|
|||||||
'مواد باید رایج، مختصر و مناسب منوی دیجیتال باشد.',
|
'مواد باید رایج، مختصر و مناسب منوی دیجیتال باشد.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
userPrompt: `نام غذا: ${dto.foodName}`,
|
userPrompt: `نام غذا: ${dto.foodName}`,
|
||||||
maxTokens: 150,
|
maxTokens: 20000,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,44 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||||
|
import { FilterQuery } from '@mikro-orm/core';
|
||||||
import { AiChat } from '../entities/ai-chat.entity';
|
import { AiChat } from '../entities/ai-chat.entity';
|
||||||
|
import { FindAiChatsDto } from '../dto/find-ai-chats.dto';
|
||||||
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiChatRepository extends EntityRepository<AiChat> {
|
export class AiChatRepository extends EntityRepository<AiChat> {
|
||||||
constructor(readonly em: EntityManager) {
|
constructor(readonly em: EntityManager) {
|
||||||
super(em, AiChat);
|
super(em, AiChat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAllPaginated(restaurantId: string, dto: FindAiChatsDto): Promise<PaginatedResult<AiChat>> {
|
||||||
|
const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc' } = dto;
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: FilterQuery<AiChat> = {
|
||||||
|
restaurant: { id: restaurantId },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.question = { $ilike: `%${search}%` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, total] = await this.findAndCount(where, {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||||
|
populate: ['user'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user