From 27c45974f482cb61f139e1eef360fc04e9ecccaf Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Thu, 23 Jul 2026 10:52:29 +0330 Subject: [PATCH] ai module --- command.sh | 14 + d-menu.code-workspace | 17 +- .../Migration20260723090000_addAiChats.ts | 59 ++++ src/app.module.ts | 2 + src/common/enums/message.enum.ts | 2 + src/modules/ai/ai.module.ts | 16 + src/modules/ai/controllers/ai.controller.ts | 64 ++++ src/modules/ai/dto/chat.dto.ts | 13 + .../ai/dto/generate-food-content.dto.ts | 13 + .../ai/dto/generate-food-description.dto.ts | 13 + src/modules/ai/entities/ai-chat.entity.ts | 23 ++ src/modules/ai/interface/ai-chat.interface.ts | 27 ++ src/modules/ai/providers/ai.service.ts | 274 ++++++++++++++++++ .../ai/repositories/ai-chat.repository.ts | 10 + ...restaurant-credit-transaction.interface.ts | 1 + 15 files changed, 538 insertions(+), 10 deletions(-) create mode 100644 command.sh create mode 100644 database/migrations/Migration20260723090000_addAiChats.ts create mode 100644 src/modules/ai/ai.module.ts create mode 100644 src/modules/ai/controllers/ai.controller.ts create mode 100644 src/modules/ai/dto/chat.dto.ts create mode 100644 src/modules/ai/dto/generate-food-content.dto.ts create mode 100644 src/modules/ai/dto/generate-food-description.dto.ts create mode 100644 src/modules/ai/entities/ai-chat.entity.ts create mode 100644 src/modules/ai/interface/ai-chat.interface.ts create mode 100644 src/modules/ai/providers/ai.service.ts create mode 100644 src/modules/ai/repositories/ai-chat.repository.ts diff --git a/command.sh b/command.sh new file mode 100644 index 0000000..25fe3c4 --- /dev/null +++ b/command.sh @@ -0,0 +1,14 @@ +curl --location 'https://arvancloudai.ir/gateway/models/DeepSeek-V4-Flash/GShkYZ-j5g8GPs2hizngJUlGKcqhDw14rUWb0wu-eLcYjfqUVlvxAhbcBdjg5iH_FSs0uHY0ypJj94AIKmr-gotsFuKm2FqCGbNXJcBi7-2lTu3Ep_cQp_eCSvmo9y4GWUU8j26WQN7pvm-MgH-L9aH-wEdU-qsyqgSslgaPdPigDJz29XNbVmBX5uw3fMpyrJi_I8myhEAoQ9IwpPblDvyHJHgaMAHFX5NZ77PWfyrxCUWkCnlsLZwUUnQ3YEjujmNm77mDqQQ/v1/chat/completions' \ +-H 'Authorization: apikey 4fe6d150-7951-58f5-b813-2e4bd7e74734' \ +-H 'Content-Type: application/json' \ +-d '{ + "model":"DeepSeek-V4-Flash-f5unr", + "messages":[ + { + "role":"user", + "content":"چرا آسمان آبی است؟" + } + ], + "max_tokens":1000, + "temperature":0.8 +}' \ No newline at end of file diff --git a/d-menu.code-workspace b/d-menu.code-workspace index f1e1fc0..e529cc2 100644 --- a/d-menu.code-workspace +++ b/d-menu.code-workspace @@ -1,11 +1,8 @@ { - "folders": [ - { - "path": "." - }, - { - "path": "../dmenu-admin" - } - ], - "settings": {} -} \ No newline at end of file + "folders": [ + { + "path": ".", + }, + ], + "settings": {}, +} diff --git a/database/migrations/Migration20260723090000_addAiChats.ts b/database/migrations/Migration20260723090000_addAiChats.ts new file mode 100644 index 0000000..8da7920 --- /dev/null +++ b/database/migrations/Migration20260723090000_addAiChats.ts @@ -0,0 +1,59 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260723090000_addAiChats extends Migration { + override async up(): Promise { + this.addSql(` + create table "ai_chats" ( + "id" char(26) not null, + "created_at" timestamptz not null default now(), + "updated_at" timestamptz not null default now(), + "deleted_at" timestamptz null, + "restaurant_id" char(26) not null, + "user_id" char(26) null, + "question" text not null, + "consumed_tokens" int not null, + "cost" numeric(10,0) not null, + constraint "ai_chats_pkey" primary key ("id") + ); + `); + this.addSql(`create index "ai_chats_created_at_index" on "ai_chats" ("created_at");`); + this.addSql(`create index "ai_chats_deleted_at_index" on "ai_chats" ("deleted_at");`); + this.addSql(`create index "ai_chats_restaurant_id_index" on "ai_chats" ("restaurant_id");`); + this.addSql(` + alter table "ai_chats" + add constraint "ai_chats_restaurant_id_foreign" + foreign key ("restaurant_id") references "restaurants" ("id") on update cascade; + `); + this.addSql(` + alter table "ai_chats" + add constraint "ai_chats_user_id_foreign" + foreign key ("user_id") references "users" ("id") on update cascade on delete set null; + `); + + this.addSql(` + alter table "restaurant_credit_transactions" + drop constraint if exists "restaurant_credit_transactions_reason_check"; + `); + this.addSql(` + alter table "restaurant_credit_transactions" + add constraint "restaurant_credit_transactions_reason_check" + check ("reason" in ('sms_send', 'deposit', 'adjustment', 'ai_chat')); + `); + } + + override async down(): Promise { + this.addSql(` + alter table "restaurant_credit_transactions" + drop constraint if exists "restaurant_credit_transactions_reason_check"; + `); + this.addSql(` + alter table "restaurant_credit_transactions" + add constraint "restaurant_credit_transactions_reason_check" + check ("reason" in ('sms_send', 'deposit', 'adjustment')); + `); + + this.addSql(`alter table "ai_chats" drop constraint if exists "ai_chats_user_id_foreign";`); + this.addSql(`alter table "ai_chats" drop constraint if exists "ai_chats_restaurant_id_foreign";`); + this.addSql(`drop table if exists "ai_chats" cascade;`); + } +} diff --git a/src/app.module.ts b/src/app.module.ts index b8b8bc2..5e4d5f9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -25,6 +25,7 @@ import { ContactModule } from './modules/contact/contact.module'; import { InventoryModule } from './modules/inventory/inventory.module'; import { IconsModule } from './modules/icons/icons.module'; import { SliderModule } from './modules/slider/slider.module'; +import { AiModule } from './modules/ai/ai.module'; import { CacheModule } from '@nestjs/cache-manager'; import { cacheConfig } from './config/cache.config'; @@ -61,6 +62,7 @@ import { cacheConfig } from './config/cache.config'; InventoryModule, IconsModule, SliderModule, + AiModule, ], controllers: [], providers: [], diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 665294c..1e9899f 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -600,6 +600,8 @@ export const enum SettingMessageEnum { export const enum AiMessage { NO_RESPONSE_FROM_AI_MODEL = 'خطا در دریافت پاسخ از مدل AI', + LLM_SERVICE_ERROR = 'سرویس هوش مصنوعی موقتاً در دسترس نیست', + INSUFFICIENT_CREDIT = 'اعتبار کیف پول رستوران برای استفاده از هوش مصنوعی کافی نیست', MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = 'ایمیل یافت نشد یا دسترسی آن را ندارید', EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = 'محتوای ایمیل خالی است یا قابل استخراج نیست', DESCRIPTION_IS_REQUIRED = 'شرح و هدف قالب الزامی است', diff --git a/src/modules/ai/ai.module.ts b/src/modules/ai/ai.module.ts new file mode 100644 index 0000000..337a080 --- /dev/null +++ b/src/modules/ai/ai.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { JwtModule } from '@nestjs/jwt'; +import { AiController } from './controllers/ai.controller'; +import { AiService } from './providers/ai.service'; +import { AiChatRepository } from './repositories/ai-chat.repository'; +import { AiChat } from './entities/ai-chat.entity'; +import { RestaurantsModule } from '../restaurants/restaurants.module'; + +@Module({ + imports: [MikroOrmModule.forFeature([AiChat]), JwtModule, RestaurantsModule], + controllers: [AiController], + providers: [AiService, AiChatRepository], + exports: [AiChatRepository], +}) +export class AiModule {} diff --git a/src/modules/ai/controllers/ai.controller.ts b/src/modules/ai/controllers/ai.controller.ts new file mode 100644 index 0000000..2590a0d --- /dev/null +++ b/src/modules/ai/controllers/ai.controller.ts @@ -0,0 +1,64 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AiService } from '../providers/ai.service'; +import { ChatDto } from '../dto/chat.dto'; +import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto'; +import { GenerateFoodContentDto } from '../dto/generate-food-content.dto'; +import { OptionalAuthGuard } from '../../auth/guards/optinalAuth.guard'; +import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard'; +import { RestId } from 'src/common/decorators/rest-id.decorator'; +import { UserId } from 'src/common/decorators/user-id.decorator'; +import { RestSlug } from 'src/common/decorators/rest-slug.decorator'; +import { Permissions } from 'src/common/decorators/permissions.decorator'; +import { Permission } from 'src/common/enums/permission.enum'; +import { API_HEADER_SLUG } from 'src/common/constants'; + +@ApiTags('ai') +@Controller() +export class AiController { + constructor(private readonly aiService: AiService) {} + + @UseGuards(OptionalAuthGuard) + @Post('public/ai/chat') + @ApiOperation({ summary: 'Ask the restaurant AI assistant about the menu' }) + @ApiHeader(API_HEADER_SLUG) + @ApiHeader({ + name: 'Authorization', + required: false, + description: 'Bearer token (optional authentication)', + }) + @ApiBearerAuth() + @ApiBody({ type: ChatDto }) + async chat( + @Body() dto: ChatDto, + @RestSlug() slug: string, + @RestId() restId?: string, + @UserId() userId?: string, + ) { + return this.aiService.chat(dto, { + restId: restId || undefined, + userId: userId || undefined, + slug, + }); + } + + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_FOODS) + @Post('admin/ai/food-description') + @ApiBearerAuth() + @ApiOperation({ summary: 'Generate a two-line food description from a food name' }) + @ApiBody({ type: GenerateFoodDescriptionDto }) + async generateFoodDescription(@Body() dto: GenerateFoodDescriptionDto, @RestId() restId: string) { + return this.aiService.generateFoodDescription(dto, restId); + } + + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_FOODS) + @Post('admin/ai/food-content') + @ApiBearerAuth() + @ApiOperation({ summary: 'Generate food ingredients/contents from a food name' }) + @ApiBody({ type: GenerateFoodContentDto }) + async generateFoodContent(@Body() dto: GenerateFoodContentDto, @RestId() restId: string) { + return this.aiService.generateFoodContent(dto, restId); + } +} diff --git a/src/modules/ai/dto/chat.dto.ts b/src/modules/ai/dto/chat.dto.ts new file mode 100644 index 0000000..cc31c33 --- /dev/null +++ b/src/modules/ai/dto/chat.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ChatDto { + @IsNotEmpty() + @IsString() + @MaxLength(2000) + @ApiProperty({ + example: 'کدام غذا برای گیاه‌خواران مناسب است؟', + description: 'User question for the restaurant AI assistant', + }) + question!: string; +} diff --git a/src/modules/ai/dto/generate-food-content.dto.ts b/src/modules/ai/dto/generate-food-content.dto.ts new file mode 100644 index 0000000..e76fbe4 --- /dev/null +++ b/src/modules/ai/dto/generate-food-content.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class GenerateFoodContentDto { + @IsNotEmpty() + @IsString() + @MaxLength(200) + @ApiProperty({ + example: 'چلو کباب کوبیده', + description: 'Food name to generate ingredients/contents for', + }) + foodName!: string; +} diff --git a/src/modules/ai/dto/generate-food-description.dto.ts b/src/modules/ai/dto/generate-food-description.dto.ts new file mode 100644 index 0000000..84ffc3e --- /dev/null +++ b/src/modules/ai/dto/generate-food-description.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class GenerateFoodDescriptionDto { + @IsNotEmpty() + @IsString() + @MaxLength(200) + @ApiProperty({ + example: 'چلو کباب کوبیده', + description: 'Food name to generate a short description for', + }) + foodName!: string; +} diff --git a/src/modules/ai/entities/ai-chat.entity.ts b/src/modules/ai/entities/ai-chat.entity.ts new file mode 100644 index 0000000..03cd1d6 --- /dev/null +++ b/src/modules/ai/entities/ai-chat.entity.ts @@ -0,0 +1,23 @@ +import { Entity, Index, ManyToOne, Property } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { Restaurant } from '../../restaurants/entities/restaurant.entity'; +import { User } from '../../users/entities/user.entity'; + +@Entity({ tableName: 'ai_chats' }) +@Index({ properties: ['restaurant'] }) +export class AiChat extends BaseEntity { + @ManyToOne(() => Restaurant) + restaurant!: Restaurant; + + @ManyToOne(() => User, { nullable: true }) + user?: User | null; + + @Property({ type: 'text' }) + question!: string; + + @Property({ type: 'int' }) + consumedTokens!: number; + + @Property({ type: 'decimal', precision: 10, scale: 0 }) + cost!: number; +} diff --git a/src/modules/ai/interface/ai-chat.interface.ts b/src/modules/ai/interface/ai-chat.interface.ts new file mode 100644 index 0000000..3ec2fed --- /dev/null +++ b/src/modules/ai/interface/ai-chat.interface.ts @@ -0,0 +1,27 @@ +export interface AiChatCompletionUsage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +} + +export interface AiChatCompletionChoice { + finish_reason?: string | null; + message?: { + role?: string; + content?: string | null; + reasoning_content?: string | null; + }; +} + +export interface AiChatCompletionResponse { + choices?: AiChatCompletionChoice[]; + usage?: AiChatCompletionUsage; +} + +export interface AiChatResult { + answer: string; + consumedTokens: number; + cost: number; + promptTokens: number; + completionTokens: number; +} diff --git a/src/modules/ai/providers/ai.service.ts b/src/modules/ai/providers/ai.service.ts new file mode 100644 index 0000000..5751284 --- /dev/null +++ b/src/modules/ai/providers/ai.service.ts @@ -0,0 +1,274 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { AxiosError } from 'axios'; +import { catchError, firstValueFrom, throwError } from 'rxjs'; +import { ChatDto } from '../dto/chat.dto'; +import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto'; +import { GenerateFoodContentDto } from '../dto/generate-food-content.dto'; +import { AiChat } from '../entities/ai-chat.entity'; +import { AiChatCompletionResponse, AiChatResult } from '../interface/ai-chat.interface'; +import { Restaurant } from '../../restaurants/entities/restaurant.entity'; +import { User } from '../../users/entities/user.entity'; +import { Food } from '../../foods/entities/food.entity'; +import { RestaurantWalletService } from '../../restaurants/providers/restaurant-wallet.service'; +import { + RestaurantCreditTransactionReason, + RestaurantCreditTransactionType, +} from '../../restaurants/interface/restaurant-credit-transaction.interface'; +import { AiMessage, RestMessage } from 'src/common/enums/message.enum'; + +@Injectable() +export class AiService { + private readonly logger = new Logger(AiService.name); + + constructor( + private readonly em: EntityManager, + private readonly httpService: HttpService, + private readonly configService: ConfigService, + private readonly restaurantWalletService: RestaurantWalletService, + ) { } + + async chat(dto: ChatDto, params: { restId?: string; userId?: string; slug?: string }): Promise { + const restaurant = await this.resolveRestaurant(params.restId, params.slug); + const user = params.userId ? await this.em.findOne(User, { id: params.userId }) : null; + + await this.ensureHasCredit(restaurant.id); + + const foods = await this.em.find( + Food, + { restaurant: restaurant.id, isActive: true }, + { fields: ['title', 'desc', 'content'], orderBy: { order: 'asc' } }, + ); + + const menuContext = this.buildMenuContext(foods); + const aiResponse = await this.callAiApi({ + systemPrompt: [ + 'تو دستیار هوشمند منوی رستوران هستی.', + 'فقط بر اساس منوی زیر به سوالات کاربر پاسخ بده.', + 'اگر اطلاعات کافی در منو نیست، صادقانه بگو.', + 'خیلی کوتاه جواب بده', + '', + 'منوی رستوران:', + menuContext, + ].join('\n'), + userPrompt: dto.question, + }); + + return this.persistAiUsage({ + restaurant, + user, + question: dto.question, + aiResponse, + }); + } + + async generateFoodDescription(dto: GenerateFoodDescriptionDto, restId: string): Promise { + const restaurant = await this.resolveRestaurant(restId); + await this.ensureHasCredit(restaurant.id); + + const aiResponse = await this.callAiApi({ + systemPrompt: [ + 'تو نویسنده منوی رستوران هستی.', + 'برای نام غذای داده‌شده، دقیقاً یک توضیح کوتاه دو خطی به فارسی بنویس.', + 'فقط همان دو خط را برگردان؛ بدون عنوان، بدون بولت و بدون توضیح اضافه.', + 'لحن باید جذاب، مختصر و مناسب منوی دیجیتال باشد.', + ].join('\n'), + userPrompt: `نام غذا: ${dto.foodName}`, + maxTokens: 150, + temperature: 0.7, + }); + + return this.persistAiUsage({ + restaurant, + question: `تولید توضیح برای غذا: ${dto.foodName}`, + aiResponse, + }); + } + + async generateFoodContent(dto: GenerateFoodContentDto, restId: string): Promise { + const restaurant = await this.resolveRestaurant(restId); + await this.ensureHasCredit(restaurant.id); + + const aiResponse = await this.callAiApi({ + systemPrompt: [ + 'تو نویسنده منوی رستوران هستی.', + 'برای نام غذای داده‌شده، محتویات و مواد تشکیل‌دهنده را به فارسی بنویس.', + 'فقط لیست مواد را با ویرگول فارسی (،) جدا کن؛ بدون عنوان، بدون بولت و بدون توضیح اضافه.', + 'مواد باید رایج، مختصر و مناسب منوی دیجیتال باشد.', + ].join('\n'), + userPrompt: `نام غذا: ${dto.foodName}`, + maxTokens: 150, + temperature: 0.7, + }); + + return this.persistAiUsage({ + restaurant, + question: `تولید محتویات برای غذا: ${dto.foodName}`, + aiResponse, + }); + } + + private async ensureHasCredit(restaurantId: string): Promise { + const balance = await this.restaurantWalletService.getCurrentBalance(restaurantId); + if (balance <= 0) { + throw new BadRequestException(AiMessage.INSUFFICIENT_CREDIT); + } + } + + private async persistAiUsage(params: { + restaurant: Restaurant; + user?: User | null; + question: string; + aiResponse: AiChatCompletionResponse; + }): Promise { + const { restaurant, user = null, question, aiResponse } = params; + + const promptTokens = Number(aiResponse.usage?.prompt_tokens ?? 0); + const completionTokens = Number(aiResponse.usage?.completion_tokens ?? 0); + const consumedTokens = Number(aiResponse.usage?.total_tokens ?? promptTokens + completionTokens); + const choice = aiResponse.choices?.[0]; + const answer = choice?.message?.content?.trim(); + + if (!answer) { + this.logger.error( + `Empty AI content. finish_reason=${choice?.finish_reason ?? 'unknown'} ` + + `reasoning_tokens=${choice?.message?.reasoning_content?.length ?? 0} ` + + `usage=${JSON.stringify(aiResponse.usage ?? null)}`, + ); + throw new ServiceUnavailableException(AiMessage.NO_RESPONSE_FROM_AI_MODEL); + } + + // Prices in .env are per 1,000,000 tokens + const promptTokenPricePerMillion = Number(this.configService.getOrThrow('AI_PROMPT_TOKEN_PRICE_PER_MILLION')); + const completionTokenPricePerMillion = Number( + this.configService.getOrThrow('AI_COMPLETION_TOKEN_PRICE_PER_MILLION'), + ); + const cost = Math.ceil( + (promptTokens * promptTokenPricePerMillion + completionTokens * completionTokenPricePerMillion) / + 1_000_000, + ); + + await this.em.transactional(async (em) => { + const aiChat = em.create(AiChat, { + restaurant, + user, + question, + consumedTokens, + cost, + }); + await em.persistAndFlush(aiChat); + + if (cost > 0) { + await this.restaurantWalletService.createTransaction({ + restaurant, + amount: cost, + type: RestaurantCreditTransactionType.DEBIT, + reason: RestaurantCreditTransactionReason.AI_CHAT, + insufficientBalanceMessage: AiMessage.INSUFFICIENT_CREDIT, + em, + }); + } + }); + + return { + answer, + consumedTokens, + cost, + promptTokens, + completionTokens, + }; + } + + private async resolveRestaurant(restId?: string, slug?: string): Promise { + if (!restId && !slug) { + throw new BadRequestException('شناسه یا اسلاگ رستوران الزامی است'); + } + + const restaurant = await this.em.findOne(Restaurant, { + ...(restId ? { id: restId } : {}), + ...(slug ? { slug } : {}), + }); + + if (!restaurant) { + throw new NotFoundException(RestMessage.NOT_FOUND); + } + + return restaurant; + } + + private buildMenuContext(foods: Array>): string { + if (!foods.length) { + return 'منوی رستوران در حال حاضر خالی است.'; + } + + return foods + .map((food, index) => { + const contents = food.content?.length ? food.content.join('، ') : 'نامشخص'; + return [ + `${index + 1}. نام: ${food.title ?? 'بدون نام'}`, + `توضیحات: ${food.desc ?? 'ندارد'}`, + `محتویات: ${contents}`, + ].join('\n'); + }) + .join('\n\n'); + } + + private async callAiApi(params: { + systemPrompt: string; + userPrompt: string; + maxTokens?: number; + temperature?: number; + }): Promise { + const apiUrl = this.configService.getOrThrow('AI_API_URL'); + const apiKey = this.configService.getOrThrow('AI_API_KEY'); + const model = this.configService.getOrThrow('AI_MODEL'); + const maxTokens = params.maxTokens ?? Number(this.configService.get('AI_MAX_TOKENS') ?? 1000); + const temperature = params.temperature ?? Number(this.configService.get('AI_TEMPERATURE') ?? 0.8); + + try { + const { data } = await firstValueFrom( + this.httpService + .post( + apiUrl, + { + model, + messages: [ + { role: 'system', content: params.systemPrompt }, + { role: 'user', content: params.userPrompt }, + ], + max_tokens: maxTokens, + temperature, + // DeepSeek V4 thinking is on by default; CoT can exhaust max_tokens and leave content empty. + thinking: { type: 'disabled' }, + }, + { + headers: { + Authorization: `apikey ${apiKey}`, + 'Content-Type': 'application/json', + }, + timeout: 60000, + }, + ) + .pipe( + catchError((err: AxiosError) => { + this.logger.error(`AI API request failed: ${err.message}`, err.stack); + return throwError(() => err); + }), + ), + ); + + return data; + } catch (error: unknown) { + this.logger.error(`AI API error: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw new ServiceUnavailableException(AiMessage.LLM_SERVICE_ERROR); + } + } +} diff --git a/src/modules/ai/repositories/ai-chat.repository.ts b/src/modules/ai/repositories/ai-chat.repository.ts new file mode 100644 index 0000000..6375575 --- /dev/null +++ b/src/modules/ai/repositories/ai-chat.repository.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { AiChat } from '../entities/ai-chat.entity'; + +@Injectable() +export class AiChatRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, AiChat); + } +} diff --git a/src/modules/restaurants/interface/restaurant-credit-transaction.interface.ts b/src/modules/restaurants/interface/restaurant-credit-transaction.interface.ts index f285071..bc261c8 100644 --- a/src/modules/restaurants/interface/restaurant-credit-transaction.interface.ts +++ b/src/modules/restaurants/interface/restaurant-credit-transaction.interface.ts @@ -7,4 +7,5 @@ export enum RestaurantCreditTransactionReason { SMS_SEND = 'sms_send', DEPOSIT = 'deposit', ADJUSTMENT = 'adjustment', + AI_CHAT = 'ai_chat', }