ai module

This commit is contained in:
2026-07-23 10:52:29 +03:30
parent 549da39473
commit 27c45974f4
15 changed files with 538 additions and 10 deletions
+14
View File
@@ -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
}'
+2 -5
View File
@@ -1,11 +1,8 @@
{ {
"folders": [ "folders": [
{ {
"path": "." "path": ".",
}, },
{
"path": "../dmenu-admin"
}
], ],
"settings": {} "settings": {},
} }
@@ -0,0 +1,59 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260723090000_addAiChats extends Migration {
override async up(): Promise<void> {
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<void> {
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;`);
}
}
+2
View File
@@ -25,6 +25,7 @@ import { ContactModule } from './modules/contact/contact.module';
import { InventoryModule } from './modules/inventory/inventory.module'; import { InventoryModule } from './modules/inventory/inventory.module';
import { IconsModule } from './modules/icons/icons.module'; import { IconsModule } from './modules/icons/icons.module';
import { SliderModule } from './modules/slider/slider.module'; import { SliderModule } from './modules/slider/slider.module';
import { AiModule } from './modules/ai/ai.module';
import { CacheModule } from '@nestjs/cache-manager'; import { CacheModule } from '@nestjs/cache-manager';
import { cacheConfig } from './config/cache.config'; import { cacheConfig } from './config/cache.config';
@@ -61,6 +62,7 @@ import { cacheConfig } from './config/cache.config';
InventoryModule, InventoryModule,
IconsModule, IconsModule,
SliderModule, SliderModule,
AiModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],
+2
View File
@@ -600,6 +600,8 @@ export const enum SettingMessageEnum {
export const enum AiMessage { export const enum AiMessage {
NO_RESPONSE_FROM_AI_MODEL = 'خطا در دریافت پاسخ از مدل AI', NO_RESPONSE_FROM_AI_MODEL = 'خطا در دریافت پاسخ از مدل AI',
LLM_SERVICE_ERROR = 'سرویس هوش مصنوعی موقتاً در دسترس نیست',
INSUFFICIENT_CREDIT = 'اعتبار کیف پول رستوران برای استفاده از هوش مصنوعی کافی نیست',
MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = 'ایمیل یافت نشد یا دسترسی آن را ندارید', MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = 'ایمیل یافت نشد یا دسترسی آن را ندارید',
EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = 'محتوای ایمیل خالی است یا قابل استخراج نیست', EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = 'محتوای ایمیل خالی است یا قابل استخراج نیست',
DESCRIPTION_IS_REQUIRED = 'شرح و هدف قالب الزامی است', DESCRIPTION_IS_REQUIRED = 'شرح و هدف قالب الزامی است',
+16
View File
@@ -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 {}
@@ -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);
}
}
+13
View File
@@ -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;
}
@@ -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;
}
@@ -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;
}
+23
View File
@@ -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;
}
@@ -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;
}
+274
View File
@@ -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<AiChatResult> {
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<AiChatResult> {
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<AiChatResult> {
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<void> {
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<AiChatResult> {
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<string>('AI_PROMPT_TOKEN_PRICE_PER_MILLION'));
const completionTokenPricePerMillion = Number(
this.configService.getOrThrow<string>('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<Restaurant> {
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<Pick<Food, 'title' | 'desc' | 'content'>>): 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<AiChatCompletionResponse> {
const apiUrl = this.configService.getOrThrow<string>('AI_API_URL');
const apiKey = this.configService.getOrThrow<string>('AI_API_KEY');
const model = this.configService.getOrThrow<string>('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<AiChatCompletionResponse>(
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);
}
}
}
@@ -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<AiChat> {
constructor(readonly em: EntityManager) {
super(em, AiChat);
}
}
@@ -7,4 +7,5 @@ export enum RestaurantCreditTransactionReason {
SMS_SEND = 'sms_send', SMS_SEND = 'sms_send',
DEPOSIT = 'deposit', DEPOSIT = 'deposit',
ADJUSTMENT = 'adjustment', ADJUSTMENT = 'adjustment',
AI_CHAT = 'ai_chat',
} }