erp-vidrio-templado-backend-v2/src/modules/ai/services/ai.service.ts
Adrian Flores Cortes bc5a389edb [PROP-CORE-004] feat: Add Phase 6 modules from erp-core
Propagated modules:
- payment-terminals: MercadoPago + Clip TPV
- ai: Role-based AI access (ADMIN, SUPERVISOR_PRODUCCION, OPERADOR_CORTE, OPERADOR_HORNO)
- mcp: 18 ERP tools for AI assistants

Priority: P2 (industrial clients pay via transfer)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 02:45:48 -06:00

383 lines
11 KiB
TypeScript

import { Repository, FindOptionsWhere, LessThan, MoreThanOrEqual } from 'typeorm';
import { AIModel, AIConversation, AIMessage, AIPrompt, AIUsageLog, AITenantQuota } from '../entities';
export interface ConversationFilters {
userId?: string;
modelId?: string;
status?: string;
startDate?: Date;
endDate?: Date;
}
export class AIService {
constructor(
private readonly modelRepository: Repository<AIModel>,
private readonly conversationRepository: Repository<AIConversation>,
private readonly messageRepository: Repository<AIMessage>,
private readonly promptRepository: Repository<AIPrompt>,
private readonly usageLogRepository: Repository<AIUsageLog>,
private readonly quotaRepository: Repository<AITenantQuota>
) {}
// ============================================
// MODELS
// ============================================
async findAllModels(): Promise<AIModel[]> {
return this.modelRepository.find({
where: { isActive: true },
order: { provider: 'ASC', name: 'ASC' },
});
}
async findModel(id: string): Promise<AIModel | null> {
return this.modelRepository.findOne({ where: { id } });
}
async findModelByCode(code: string): Promise<AIModel | null> {
return this.modelRepository.findOne({ where: { code } });
}
async findModelsByProvider(provider: string): Promise<AIModel[]> {
return this.modelRepository.find({
where: { provider: provider as any, isActive: true },
order: { name: 'ASC' },
});
}
async findModelsByType(modelType: string): Promise<AIModel[]> {
return this.modelRepository.find({
where: { modelType: modelType as any, isActive: true },
order: { name: 'ASC' },
});
}
// ============================================
// PROMPTS
// ============================================
async findAllPrompts(tenantId?: string): Promise<AIPrompt[]> {
if (tenantId) {
return this.promptRepository.find({
where: [{ tenantId, isActive: true }, { isSystem: true, isActive: true }],
order: { category: 'ASC', name: 'ASC' },
});
}
return this.promptRepository.find({
where: { isActive: true },
order: { category: 'ASC', name: 'ASC' },
});
}
async findPrompt(id: string): Promise<AIPrompt | null> {
return this.promptRepository.findOne({ where: { id } });
}
async findPromptByCode(code: string, tenantId?: string): Promise<AIPrompt | null> {
if (tenantId) {
// Try tenant-specific first, then system prompt
const tenantPrompt = await this.promptRepository.findOne({
where: { code, tenantId, isActive: true },
});
if (tenantPrompt) return tenantPrompt;
return this.promptRepository.findOne({
where: { code, isSystem: true, isActive: true },
});
}
return this.promptRepository.findOne({ where: { code, isActive: true } });
}
async createPrompt(
tenantId: string,
data: Partial<AIPrompt>,
createdBy?: string
): Promise<AIPrompt> {
const prompt = this.promptRepository.create({
...data,
tenantId,
createdBy,
version: 1,
});
return this.promptRepository.save(prompt);
}
async updatePrompt(
id: string,
data: Partial<AIPrompt>,
updatedBy?: string
): Promise<AIPrompt | null> {
const prompt = await this.findPrompt(id);
if (!prompt) return null;
if (prompt.isSystem) {
throw new Error('Cannot update system prompts');
}
Object.assign(prompt, data, { updatedBy, version: prompt.version + 1 });
return this.promptRepository.save(prompt);
}
async incrementPromptUsage(id: string): Promise<void> {
await this.promptRepository
.createQueryBuilder()
.update()
.set({
usageCount: () => 'usage_count + 1',
})
.where('id = :id', { id })
.execute();
}
// ============================================
// CONVERSATIONS
// ============================================
async findConversations(
tenantId: string,
filters: ConversationFilters = {},
limit: number = 50
): Promise<AIConversation[]> {
const where: FindOptionsWhere<AIConversation> = { tenantId };
if (filters.userId) where.userId = filters.userId;
if (filters.modelId) where.modelId = filters.modelId;
if (filters.status) where.status = filters.status as any;
return this.conversationRepository.find({
where,
order: { updatedAt: 'DESC' },
take: limit,
});
}
async findConversation(id: string): Promise<AIConversation | null> {
return this.conversationRepository.findOne({
where: { id },
relations: ['messages'],
});
}
async findUserConversations(
tenantId: string,
userId: string,
limit: number = 20
): Promise<AIConversation[]> {
return this.conversationRepository.find({
where: { tenantId, userId },
order: { updatedAt: 'DESC' },
take: limit,
});
}
async createConversation(
tenantId: string,
userId: string,
data: Partial<AIConversation>
): Promise<AIConversation> {
const conversation = this.conversationRepository.create({
...data,
tenantId,
userId,
status: 'active',
});
return this.conversationRepository.save(conversation);
}
async updateConversation(
id: string,
data: Partial<AIConversation>
): Promise<AIConversation | null> {
const conversation = await this.conversationRepository.findOne({ where: { id } });
if (!conversation) return null;
Object.assign(conversation, data);
return this.conversationRepository.save(conversation);
}
async archiveConversation(id: string): Promise<boolean> {
const result = await this.conversationRepository.update(id, { status: 'archived' });
return (result.affected ?? 0) > 0;
}
// ============================================
// MESSAGES
// ============================================
async findMessages(conversationId: string): Promise<AIMessage[]> {
return this.messageRepository.find({
where: { conversationId },
order: { createdAt: 'ASC' },
});
}
async addMessage(conversationId: string, data: Partial<AIMessage>): Promise<AIMessage> {
const message = this.messageRepository.create({
...data,
conversationId,
});
const savedMessage = await this.messageRepository.save(message);
// Update conversation
await this.conversationRepository
.createQueryBuilder()
.update()
.set({
messageCount: () => 'message_count + 1',
totalTokens: () => `total_tokens + ${data.totalTokens || 0}`,
updatedAt: new Date(),
})
.where('id = :id', { id: conversationId })
.execute();
return savedMessage;
}
async getConversationTokenCount(conversationId: string): Promise<number> {
const result = await this.messageRepository
.createQueryBuilder('message')
.select('SUM(message.total_tokens)', 'total')
.where('message.conversation_id = :conversationId', { conversationId })
.getRawOne();
return parseInt(result?.total) || 0;
}
// ============================================
// USAGE & QUOTAS
// ============================================
async logUsage(tenantId: string, data: Partial<AIUsageLog>): Promise<AIUsageLog> {
const log = this.usageLogRepository.create({
...data,
tenantId,
});
return this.usageLogRepository.save(log);
}
async getUsageStats(
tenantId: string,
startDate: Date,
endDate: Date
): Promise<{
totalRequests: number;
totalInputTokens: number;
totalOutputTokens: number;
totalCost: number;
byModel: Record<string, { requests: number; tokens: number; cost: number }>;
}> {
const stats = await this.usageLogRepository
.createQueryBuilder('log')
.select('COUNT(*)', 'totalRequests')
.addSelect('SUM(log.input_tokens)', 'totalInputTokens')
.addSelect('SUM(log.output_tokens)', 'totalOutputTokens')
.addSelect('SUM(log.cost_usd)', 'totalCost')
.where('log.tenant_id = :tenantId', { tenantId })
.andWhere('log.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.getRawOne();
const byModelStats = await this.usageLogRepository
.createQueryBuilder('log')
.select('log.model_id', 'modelId')
.addSelect('COUNT(*)', 'requests')
.addSelect('SUM(log.input_tokens + log.output_tokens)', 'tokens')
.addSelect('SUM(log.cost_usd)', 'cost')
.where('log.tenant_id = :tenantId', { tenantId })
.andWhere('log.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy('log.model_id')
.getRawMany();
const byModel: Record<string, { requests: number; tokens: number; cost: number }> = {};
for (const stat of byModelStats) {
byModel[stat.modelId] = {
requests: parseInt(stat.requests) || 0,
tokens: parseInt(stat.tokens) || 0,
cost: parseFloat(stat.cost) || 0,
};
}
return {
totalRequests: parseInt(stats?.totalRequests) || 0,
totalInputTokens: parseInt(stats?.totalInputTokens) || 0,
totalOutputTokens: parseInt(stats?.totalOutputTokens) || 0,
totalCost: parseFloat(stats?.totalCost) || 0,
byModel,
};
}
async getTenantQuota(tenantId: string): Promise<AITenantQuota | null> {
return this.quotaRepository.findOne({ where: { tenantId } });
}
async updateTenantQuota(
tenantId: string,
data: Partial<AITenantQuota>
): Promise<AITenantQuota> {
let quota = await this.getTenantQuota(tenantId);
if (!quota) {
quota = this.quotaRepository.create({
tenantId,
...data,
});
} else {
Object.assign(quota, data);
}
return this.quotaRepository.save(quota);
}
async incrementQuotaUsage(
tenantId: string,
requestCount: number,
tokenCount: number,
costUsd: number
): Promise<void> {
await this.quotaRepository
.createQueryBuilder()
.update()
.set({
currentRequests: () => `current_requests + ${requestCount}`,
currentTokens: () => `current_tokens + ${tokenCount}`,
currentCost: () => `current_cost + ${costUsd}`,
})
.where('tenant_id = :tenantId', { tenantId })
.execute();
}
async checkQuotaAvailable(tenantId: string): Promise<{
available: boolean;
reason?: string;
}> {
const quota = await this.getTenantQuota(tenantId);
if (!quota) return { available: true };
if (quota.monthlyRequestLimit && quota.currentRequests >= quota.monthlyRequestLimit) {
return { available: false, reason: 'Monthly request limit reached' };
}
if (quota.monthlyTokenLimit && quota.currentTokens >= quota.monthlyTokenLimit) {
return { available: false, reason: 'Monthly token limit reached' };
}
if (quota.monthlyCostLimit && quota.currentCost >= quota.monthlyCostLimit) {
return { available: false, reason: 'Monthly spend limit reached' };
}
return { available: true };
}
async resetMonthlyQuotas(): Promise<number> {
const result = await this.quotaRepository.update(
{},
{
currentRequests: 0,
currentTokens: 0,
currentCost: 0,
}
);
return result.affected ?? 0;
}
}