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>
383 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|