3 Commits
11 changed files with 477 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>;
}
+102
View File
@@ -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.`,
);
}
}
}