feat: add AI chatbot module and Google Gemini integration

This commit is contained in:
Syed Waseem
2026-08-31 16:05:01 +05:30
parent cf458536fb
commit 8341382b1d
11 changed files with 515 additions and 0 deletions
+2
View File
@@ -11,6 +11,7 @@ import { PolicyEngineModule } from './modules/policy-engine/policy-engine.module
import { RecoveryIncidentModule } from './modules/recovery-incident/recovery-incident.module';
import { AuditLogModule } from './modules/audit-log/audit-log.module';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { ChatModule } from './modules/chat/chat.module';
import { AuditInterceptor } from './common/interceptors/audit.interceptor';
const env = process.env.NODE_ENV;
@@ -31,6 +32,7 @@ const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local',
RecoveryIncidentModule,
AuditLogModule,
DashboardModule,
ChatModule,
],
providers: [
{
+1
View File
@@ -38,6 +38,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "policy_engine";`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "recovery_incident";`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "audit";`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "chat";`);
// Run synchronization manually if it was enabled
if (options.synchronize) {
+85
View File
@@ -0,0 +1,85 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Headers,
} from '@nestjs/common';
import { ChatService } from './chat.service';
import { CreateConversationDto } from './dto/create-conversation.dto';
import { SendMessageDto } from './dto/send-message.dto';
import { getTenantIdOrNull } from '../../common/tenant/tenant.context';
const DEFAULT_TENANT_ID = '00000000-0000-0000-0000-000000000001';
const DEFAULT_USER_ID = 'demo-user-001';
@Controller('chat')
export class ChatController {
constructor(private readonly chatService: ChatService) {}
private resolveContext(headers?: Record<string, any>) {
const tenantId =
getTenantIdOrNull() ||
headers?.['x-tenant-id'] ||
DEFAULT_TENANT_ID;
const userId = headers?.['x-user-id'] || DEFAULT_USER_ID;
return { tenantId, userId };
}
@Post('conversations')
async createConversation(
@Body() dto: CreateConversationDto,
@Headers() headers: Record<string, any>,
) {
const { tenantId, userId } = this.resolveContext(headers);
return this.chatService.createConversation(tenantId, userId, dto);
}
@Get('conversations')
async getUserConversations(@Headers() headers: Record<string, any>) {
const { tenantId, userId } = this.resolveContext(headers);
const conversations = await this.chatService.getUserConversations(
tenantId,
userId,
);
return { conversations };
}
@Get('conversations/:id/messages')
async getConversationMessages(
@Param('id') conversationId: string,
@Headers() headers: Record<string, any>,
) {
const { tenantId, userId } = this.resolveContext(headers);
const messages = await this.chatService.getConversationMessages(
tenantId,
userId,
conversationId,
);
return { conversationId, messages };
}
@Post('messages')
async sendMessage(
@Body() dto: SendMessageDto,
@Headers() headers: Record<string, any>,
) {
const { tenantId, userId } = this.resolveContext(headers);
return this.chatService.sendMessage(tenantId, userId, dto);
}
@Delete('conversations/:id')
async deleteConversation(
@Param('id') conversationId: string,
@Headers() headers: Record<string, any>,
) {
const { tenantId, userId } = this.resolveContext(headers);
return this.chatService.deleteConversation(
tenantId,
userId,
conversationId,
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ChatConversation } from './entities/chat-conversation.entity';
import { ChatMessage } from './entities/chat-message.entity';
import { ChatService } from './chat.service';
import { ChatController } from './chat.controller';
import { LlmService } from './llm/llm.service';
@Module({
imports: [TypeOrmModule.forFeature([ChatConversation, ChatMessage])],
controllers: [ChatController],
providers: [ChatService, LlmService],
exports: [ChatService, LlmService],
})
export class ChatModule {}
+172
View File
@@ -0,0 +1,172 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ChatConversation } from './entities/chat-conversation.entity';
import { ChatMessage } from './entities/chat-message.entity';
import { LlmService } from './llm/llm.service';
import { CreateConversationDto } from './dto/create-conversation.dto';
import { SendMessageDto } from './dto/send-message.dto';
const MAX_HISTORY_MESSAGES = 20;
@Injectable()
export class ChatService {
private readonly logger = new Logger(ChatService.name);
constructor(
@InjectRepository(ChatConversation)
private readonly conversationRepo: Repository<ChatConversation>,
@InjectRepository(ChatMessage)
private readonly messageRepo: Repository<ChatMessage>,
private readonly llmService: LlmService,
) { }
async createConversation(
tenantId: string,
userId: string,
dto?: CreateConversationDto,
): Promise<ChatConversation> {
const conversation = this.conversationRepo.create({
tenantId,
userId,
title: dto?.title || 'New Conversation',
});
return this.conversationRepo.save(conversation);
}
async getUserConversations(
tenantId: string,
userId: string,
): Promise<ChatConversation[]> {
return this.conversationRepo.find({
where: { tenantId, userId },
order: { updatedAt: 'DESC' },
});
}
async getConversationMessages(
tenantId: string,
userId: string,
conversationId: string,
): Promise<ChatMessage[]> {
const conversation = await this.conversationRepo.findOne({
where: { id: conversationId, tenantId },
});
if (!conversation) {
throw new NotFoundException('Conversation not found');
}
if (conversation.userId && conversation.userId !== userId) {
throw new ForbiddenException('Access to conversation denied');
}
return this.messageRepo.find({
where: { conversationId },
order: { createdAt: 'ASC' },
});
}
async sendMessage(
tenantId: string,
userId: string,
dto: SendMessageDto,
): Promise<{
messageId: string;
conversationId: string;
role: string;
content: string;
createdAt: Date;
}> {
const { conversationId, message } = dto;
const conversation = await this.conversationRepo.findOne({
where: { id: conversationId, tenantId },
});
if (!conversation) {
throw new NotFoundException('Conversation not found');
}
if (conversation.userId && conversation.userId !== userId) {
throw new ForbiddenException('Access to conversation denied');
}
// 1. Save User Message
const userMessage = this.messageRepo.create({
conversationId,
role: 'user',
content: message,
});
await this.messageRepo.save(userMessage);
// 2. Auto-generate conversation title from first user message if default
if (
conversation.title === 'New Conversation' ||
!conversation.title.trim()
) {
const truncatedTitle =
message.length > 30 ? message.substring(0, 30) + '...' : message;
conversation.title = truncatedTitle;
}
conversation.updatedAt = new Date();
await this.conversationRepo.save(conversation);
// 3. Load recent history for context
const recentMessages = await this.messageRepo.find({
where: { conversationId },
order: { createdAt: 'DESC' },
take: MAX_HISTORY_MESSAGES,
});
const orderedHistory = recentMessages.reverse().map((m) => ({
role: m.role,
content: m.content,
}));
// 4. Generate LLM Response
const assistantContent = await this.llmService.generateResponse(orderedHistory);
// 5. Save Assistant Message
const assistantMessage = this.messageRepo.create({
conversationId,
role: 'assistant',
content: assistantContent,
});
const savedAssistantMsg = await this.messageRepo.save(assistantMessage);
return {
messageId: savedAssistantMsg.id,
conversationId,
role: savedAssistantMsg.role,
content: savedAssistantMsg.content,
createdAt: savedAssistantMsg.createdAt,
};
}
async deleteConversation(
tenantId: string,
userId: string,
conversationId: string,
): Promise<{ success: boolean }> {
const conversation = await this.conversationRepo.findOne({
where: { id: conversationId, tenantId },
});
if (!conversation) {
throw new NotFoundException('Conversation not found');
}
if (conversation.userId && conversation.userId !== userId) {
throw new ForbiddenException('Access to conversation denied');
}
await this.conversationRepo.remove(conversation);
return { success: true };
}
}
@@ -0,0 +1,8 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateConversationDto {
@IsOptional()
@IsString()
@MaxLength(255)
title?: string;
}
+12
View File
@@ -0,0 +1,12 @@
import { IsNotEmpty, IsString, IsUUID, MaxLength } from 'class-validator';
export class SendMessageDto {
@IsNotEmpty()
@IsUUID()
conversationId!: string;
@IsNotEmpty()
@IsString()
@MaxLength(4000)
message!: string;
}
@@ -0,0 +1,33 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity';
import type { ChatMessage } from './chat-message.entity';
@Entity({ name: 'tbl_chat_conversations', schema: 'chat' })
export class ChatConversation extends TenantOwnedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ nullable: true, type: 'varchar' })
userId!: string;
@Column({ default: 'New Conversation' })
title!: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@OneToMany('ChatMessage', (message: any) => message.conversation, {
cascade: true,
})
messages!: ChatMessage[];
}
@@ -0,0 +1,36 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import type { ChatConversation } from './chat-conversation.entity';
@Entity({ name: 'tbl_chat_messages', schema: 'chat' })
@Index(['conversationId', 'createdAt'])
export class ChatMessage {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid' })
@Index()
conversationId!: string;
@ManyToOne('ChatConversation', (conversation: any) => conversation.messages, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'conversationId' })
conversation!: ChatConversation;
@Column({ type: 'varchar', length: 20 })
role!: 'user' | 'assistant' | 'system';
@Column({ type: 'text' })
content!: string;
@CreateDateColumn()
createdAt!: Date;
}
+11
View File
@@ -0,0 +1,11 @@
export interface LlmMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface ILlmService {
generateResponse(
messages: LlmMessage[],
systemPrompt?: string,
): Promise<string>;
}
+140
View File
@@ -0,0 +1,140 @@
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ILlmService, LlmMessage } from './llm.interface';
const DEFAULT_SYSTEM_PROMPT = `You are Aero Resolve AI, an intelligent assistant for the Aero Resolve application.
Your primary role is to assist airline operations staff, managers, and customer recovery officers with understanding Irregular Operations (IROPS), passenger compensation policies, flight disruptions, cohort targeting, and automated resolution workflows.
Key Instructions:
1. Answer clearly, accurately, and concisely.
2. Help users understand disruption management, EU261/DOT regulations, policy engine configuration, and cohort rules.
3. Maintain a professional, helpful, and courteous tone.
4. For Phase 1 POC: If application-specific live operational data (e.g. specific passenger booking details or PNR record lookups) is required, explain that live data integration is coming in Phase 2.
5. Provide structured, readable answers using Markdown formatting when appropriate.`;
@Injectable()
export class LlmService implements ILlmService {
private readonly logger = new Logger(LlmService.name);
constructor(private readonly configService: ConfigService) {}
async generateResponse(
messages: LlmMessage[],
systemPrompt: string = DEFAULT_SYSTEM_PROMPT,
): Promise<string> {
const apiKey =
this.configService.get<string>('GOOGLE_GEMINI_KEY') ||
this.configService.get<string>('GEMINI_API_KEY') ||
process.env.GOOGLE_GEMINI_KEY ||
process.env.GEMINI_API_KEY;
if (!apiKey) {
this.logger.error('GOOGLE_GEMINI_KEY is missing in environment variables.');
throw new InternalServerErrorException(
'GOOGLE_GEMINI_KEY is not configured. Please set GOOGLE_GEMINI_KEY in .env.local.',
);
}
const cleanApiKey = apiKey.trim().replace(/^["']|["']$/g, '');
const model =
this.configService.get<string>('GEMINI_MODEL') ||
process.env.GEMINI_MODEL ||
'gemini-2.0-flash';
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${cleanApiKey}`;
this.logger.log(`Calling Google Gemini API model: ${model}`);
const contents = messages.map((m) => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content }],
}));
const payload: any = {
contents,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 1000,
},
};
if (systemPrompt) {
payload.systemInstruction = {
parts: [{ text: systemPrompt }],
};
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errText = await response.text();
this.logger.error(`Gemini API call failed (${response.status}): ${errText}`);
if (response.status === 404 && model !== 'gemini-2.0-flash') {
this.logger.warn(`Model ${model} not found. Retrying with gemini-2.0-flash...`);
return this.retryWithModel('gemini-2.0-flash', cleanApiKey, payload);
}
throw new InternalServerErrorException(
`Google Gemini API error (${response.status}): ${errText}`,
);
}
const data = await response.json();
const outputText = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!outputText) {
this.logger.error('Gemini API returned an empty output payload.', data);
throw new InternalServerErrorException(
'Google Gemini API returned an empty response.',
);
}
return outputText.trim();
} catch (err: any) {
if (err instanceof InternalServerErrorException) {
throw err;
}
this.logger.error(`Failed to connect to Google Gemini API: ${err.message}`, err.stack);
throw new InternalServerErrorException(
`Failed to communicate with Google Gemini API: ${err.message}`,
);
}
}
private async retryWithModel(
fallbackModel: string,
apiKey: string,
payload: any,
): Promise<string> {
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${fallbackModel}:generateContent?key=${apiKey}`;
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errText = await response.text();
throw new InternalServerErrorException(
`Google Gemini API fallback error (${response.status}): ${errText}`,
);
}
const data = await response.json();
const outputText = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!outputText) {
throw new InternalServerErrorException(
'Google Gemini API fallback returned empty text.',
);
}
return outputText.trim();
}
}