Compare commits
3
Commits
development
...
chat-bot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d072a640d5 | ||
|
|
352a58b220 | ||
|
|
8341382b1d |
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface LlmMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ILlmService {
|
||||
generateResponse(
|
||||
messages: LlmMessage[],
|
||||
systemPrompt?: string,
|
||||
): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
InternalServerErrorException,
|
||||
BadGatewayException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ILlmService, LlmMessage } from './llm.interface';
|
||||
|
||||
@Injectable()
|
||||
export class LlmService implements ILlmService {
|
||||
private readonly logger = new Logger(LlmService.name);
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
private getAiServiceUrl(): string {
|
||||
const rawUrl =
|
||||
this.configService.get<string>('AI_SERVICE_URL') ||
|
||||
process.env.AI_SERVICE_URL ||
|
||||
'http://localhost:8000';
|
||||
return rawUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async generateResponse(
|
||||
messages: LlmMessage[],
|
||||
systemPrompt?: string,
|
||||
): Promise<string> {
|
||||
const aiServiceUrl = this.getAiServiceUrl();
|
||||
const endpoint = `${aiServiceUrl}/api/chat/generate`;
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
messages: messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
use_rag: true,
|
||||
};
|
||||
|
||||
if (systemPrompt) {
|
||||
payload.system_prompt = systemPrompt;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[AI Service Request] Calling ${endpoint} with ${messages.length} messages.`,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
this.logger.error(
|
||||
`[AI Service Error] ${endpoint} returned status ${response.status}: ${errorText}`,
|
||||
);
|
||||
throw new BadGatewayException(
|
||||
`AeroResolve AI Microservice error (${response.status}): ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.content;
|
||||
|
||||
if (!content) {
|
||||
this.logger.error(
|
||||
'[AI Service Error] Empty content received from AI microservice.',
|
||||
data,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
'Empty response received from AeroResolve AI microservice.',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[AI Service Response] Model used: ${data.model_used || 'default'} | Output length: ${content.length} chars`,
|
||||
);
|
||||
|
||||
return content.trim();
|
||||
} catch (err: any) {
|
||||
if (
|
||||
err instanceof BadGatewayException ||
|
||||
err instanceof InternalServerErrorException
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
`[AI Service Connection Error] Failed to connect to ${endpoint}: ${err.message}. Ensure the aeroresolve_ai Python service is running on ${aiServiceUrl}.`,
|
||||
err.stack,
|
||||
);
|
||||
|
||||
throw new InternalServerErrorException(
|
||||
`Failed to reach AeroResolve AI microservice at ${aiServiceUrl}. Please ensure the service is running.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user