diff --git a/Dockerfile b/Dockerfile index dd45481..f02b01b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /app COPY package.json bun.lockb ./ # Instalar dependencias -RUN bun install --frozen-lockfile +RUN bun install # Copiar el código fuente COPY . . diff --git a/bun.lockb b/bun.lockb index daf8845..bb2bca5 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docker-compose.yaml b/docker-compose.yaml index 0bf7c4c..a112bac 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -17,7 +17,7 @@ services: restart: unless-stopped db: - image: postgres:13 + image: postgres:16-alpine container_name: ${DATABASE_CONTAINER_NAME} env_file: - .env.docker @@ -32,4 +32,4 @@ services: restart: unless-stopped volumes: - postgres_data: \ No newline at end of file + postgres_data: diff --git a/package.json b/package.json index c168823..643d89f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@getbrevo/brevo": "^2.2.0", "@hono/zod-openapi": "^0.18.3", + "@langchain/core": "^0.3.77", "@scalar/hono-api-reference": "^0.5.162", "@types/json2csv": "^5.0.7", "@types/uuid": "^10.0.0", @@ -28,6 +29,7 @@ "i": "^0.3.7", "jose": "^5.9.6", "json2csv": "^6.0.0-alpha.2", + "langchain": "^0.3.34", "npm": "^11.3.0", "pdf-lib": "^1.17.1", "pdfkit": "^0.17.0", diff --git a/src/app.ts b/src/app.ts index 31e787f..25f8c90 100644 --- a/src/app.ts +++ b/src/app.ts @@ -38,6 +38,7 @@ import { PgGoalContributionRepository } from "./features/goals/infrastucture/ada import { ExcelService } from "./features/reports/infrastructure/services/excel.service"; import { CSVService } from "./features/reports/infrastructure/services/csv.service"; import reports from "./features/reports/infrastructure/controllers/report.controller"; +import aiAgents from "./features/ai-agents/infrastructure/controllers/voice-command.controller"; const app = createApp(); @@ -109,6 +110,7 @@ const routes = [ notifications, email, reports, + aiAgents, notificationSocket, ] as const; diff --git a/src/features/ai-agents/README.md b/src/features/ai-agents/README.md new file mode 100644 index 0000000..88c0789 --- /dev/null +++ b/src/features/ai-agents/README.md @@ -0,0 +1,92 @@ +# Sistema de Agentes de IA + +Este módulo implementa un sistema de agentes de IA para procesar comandos de voz y crear automáticamente transacciones, metas de ahorro y presupuestos. + +## Arquitectura + +### Componentes Principales + +1. **VoiceOrchestratorService**: Orquestador principal que coordina todos los agentes +2. **Agentes Especializados**: + - `TransactionAgentService`: Maneja creación de gastos e ingresos + - `GoalAgentService`: Maneja creación de metas de ahorro + - `BudgetAgentService`: Maneja creación de presupuestos + - `ValidationAgentService`: Valida y corrige datos extraídos + +### Flujo de Procesamiento + +1. **Audio Input** → El usuario graba un comando de voz +2. **Transcripción** → OpenAI Whisper convierte audio a texto +3. **Clasificación de Intención** → LLM determina el tipo de comando +4. **Extracción de Datos** → LLM extrae entidades relevantes +5. **Procesamiento por Agente** → El agente especializado procesa los datos +6. **Validación** → Se validan y corrigen los datos extraídos +7. **Respuesta** → Se devuelve la respuesta estructurada al frontend + +## Configuración + +### Variables de Entorno + +```bash +OPENAI_API_KEY=your_openai_api_key_here +``` + +### Endpoint + +``` +POST /voice-command +Authorization: Bearer +Content-Type: multipart/form-data + +Body: +- audio: File (audio/wav) +``` + +### Respuesta + +```json +{ + "success": true, + "intent": "CREATE_TRANSACTION", + "extractedData": { + "user_id": 1, + "amount": 25.50, + "type": "EXPENSE", + "description": "Almuerzo en restaurante", + "category_id": null, + "payment_method_id": null, + "date": "2024-01-15T10:30:00.000Z" + }, + "confidence": 0.95, + "message": "He identificado una transacción: Gasto de $25.5 en Almuerzo en restaurante", + "validationErrors": [], + "suggestedCorrections": {} +} +``` + +## Ejemplos de Comandos de Voz + +### Transacciones +- "Gasté 25 dólares en comida" +- "Recibí 500 dólares de mi trabajo" +- "Pagué 80 dólares en gasolina con mi tarjeta de crédito" + +### Metas de Ahorro +- "Quiero ahorrar 1000 dólares para vacaciones hasta diciembre" +- "Crear una meta de 500 dólares para emergencias" +- "Ahorrar para un auto, necesito 15000 dólares" + +### Presupuestos +- "Crear un presupuesto de 300 dólares para comida este mes" +- "Quiero limitar mis gastos en entretenimiento a 100 dólares" +- "Presupuesto de 200 dólares para transporte" + +## Extensibilidad + +Para agregar nuevos tipos de comandos: + +1. Agregar nueva intención en `CommandIntent` +2. Crear nuevo agente especializado +3. Agregar lógica en `VoiceOrchestratorService` +4. Actualizar prompts en `OpenAILLMAdapter` +5. Agregar validaciones en `ValidationAgentService` \ No newline at end of file diff --git a/src/features/ai-agents/application/services/budget-agent.service.ts b/src/features/ai-agents/application/services/budget-agent.service.ts new file mode 100644 index 0000000..3ed88cf --- /dev/null +++ b/src/features/ai-agents/application/services/budget-agent.service.ts @@ -0,0 +1,65 @@ +import { AgentResponse } from "../../domain/entities/voice-command.entity"; + +export class BudgetAgentService { + async processBudget(extractedData: Record, userId: number): Promise { + try { + const processedData = this.normalizeBudgetData(extractedData, userId); + + return { + success: true, + intent: 'CREATE_BUDGET', + extractedData: processedData, + confidence: 0.9, + message: `He identificado un presupuesto de $${processedData.limit_amount} para ${processedData.month}` + }; + } catch (error) { + return { + success: false, + intent: 'CREATE_BUDGET', + extractedData: {}, + confidence: 0, + message: "No pude procesar los datos del presupuesto" + }; + } + } + + private normalizeBudgetData(data: Record, userId: number) { + return { + user_id: userId, + category_id: data.category_id || null, + limit_amount: this.parseAmount(data.limit_amount || data.amount), + current_amount: 0, + month: this.parseMonth(data.month || data.period) + }; + } + + private parseAmount(amount: any): number { + if (typeof amount === 'number') return amount; + if (typeof amount === 'string') { + const cleanAmount = amount.replace(/[^\d.,]/g, ''); + return parseFloat(cleanAmount.replace(',', '.')); + } + return 0; + } + + private parseMonth(monthInput: any): string { + if (!monthInput) { + const currentDate = new Date(); + return new Date(currentDate.getFullYear(), currentDate.getMonth(), 1).toISOString(); + } + + if (monthInput instanceof Date) { + return new Date(monthInput.getFullYear(), monthInput.getMonth(), 1).toISOString(); + } + + if (typeof monthInput === 'string') { + const date = new Date(monthInput); + if (!isNaN(date.getTime())) { + return new Date(date.getFullYear(), date.getMonth(), 1).toISOString(); + } + } + + const currentDate = new Date(); + return new Date(currentDate.getFullYear(), currentDate.getMonth(), 1).toISOString(); + } +} \ No newline at end of file diff --git a/src/features/ai-agents/application/services/goal-agent.service.ts b/src/features/ai-agents/application/services/goal-agent.service.ts new file mode 100644 index 0000000..9ab5c77 --- /dev/null +++ b/src/features/ai-agents/application/services/goal-agent.service.ts @@ -0,0 +1,70 @@ +import { AgentResponse } from "../../domain/entities/voice-command.entity"; + +export class GoalAgentService { + async processGoal(extractedData: Record, userId: number): Promise { + try { + const processedData = this.normalizeGoalData(extractedData, userId); + + return { + success: true, + intent: 'CREATE_GOAL', + extractedData: processedData, + confidence: 0.9, + message: `He identificado una meta de ahorro: "${processedData.name}" con objetivo de $${processedData.target_amount}` + }; + } catch (error) { + return { + success: false, + intent: 'CREATE_GOAL', + extractedData: {}, + confidence: 0, + message: "No pude procesar los datos de la meta de ahorro" + }; + } + } + + private normalizeGoalData(data: Record, userId: number) { + return { + user_id: userId, + name: data.name || data.goal_name || 'Meta sin nombre', + target_amount: this.parseAmount(data.target_amount || data.amount), + current_amount: 0, + end_date: this.parseDate(data.end_date || data.deadline), + category_id: data.category_id || null, + contribution_frequency: data.contribution_frequency || null, + contribution_amount: data.contribution_amount ? this.parseAmount(data.contribution_amount) : null + }; + } + + private parseAmount(amount: any): number { + if (typeof amount === 'number') return amount; + if (typeof amount === 'string') { + const cleanAmount = amount.replace(/[^\d.,]/g, ''); + return parseFloat(cleanAmount.replace(',', '.')); + } + return 0; + } + + private parseDate(dateInput: any): string { + if (!dateInput) { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + return futureDate.toISOString(); + } + + if (dateInput instanceof Date) { + return dateInput.toISOString(); + } + + if (typeof dateInput === 'string') { + const date = new Date(dateInput); + if (!isNaN(date.getTime())) { + return date.toISOString(); + } + } + + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + return futureDate.toISOString(); + } +} \ No newline at end of file diff --git a/src/features/ai-agents/application/services/transaction-agent.service.ts b/src/features/ai-agents/application/services/transaction-agent.service.ts new file mode 100644 index 0000000..dd186ba --- /dev/null +++ b/src/features/ai-agents/application/services/transaction-agent.service.ts @@ -0,0 +1,59 @@ +import { AgentResponse } from "../../domain/entities/voice-command.entity"; + +export class TransactionAgentService { + async processTransaction(extractedData: Record, userId: number): Promise { + try { + const processedData = this.normalizeTransactionData(extractedData, userId); + + return { + success: true, + intent: 'CREATE_TRANSACTION', + extractedData: processedData, + confidence: 0.9, + message: `He identificado una transacción: ${processedData.type === 'EXPENSE' ? 'Gasto' : 'Ingreso'} de $${processedData.amount} en ${processedData.description || 'Sin descripción'}` + }; + } catch (error) { + return { + success: false, + intent: 'CREATE_TRANSACTION', + extractedData: {}, + confidence: 0, + message: "No pude procesar los datos de la transacción" + }; + } + } + + private normalizeTransactionData(data: Record, userId: number) { + return { + user_id: userId, + amount: this.parseAmount(data.amount), + type: this.determineTransactionType(data), + description: data.description || data.concept || '', + category_id: data.category_id || null, + payment_method_id: data.payment_method_id || null, + date: data.date || new Date().toISOString() + }; + } + + private parseAmount(amount: any): number { + if (typeof amount === 'number') return amount; + if (typeof amount === 'string') { + const cleanAmount = amount.replace(/[^\d.,]/g, ''); + return parseFloat(cleanAmount.replace(',', '.')); + } + return 0; + } + + private determineTransactionType(data: Record): 'INCOME' | 'EXPENSE' { + const type = data.type?.toUpperCase(); + if (type === 'INCOME' || type === 'INGRESO') return 'INCOME'; + if (type === 'EXPENSE' || type === 'GASTO') return 'EXPENSE'; + + const keywords = data.description?.toLowerCase() || ''; + if (keywords.includes('gané') || keywords.includes('recibí') || keywords.includes('ingreso')) { + return 'INCOME'; + } + + return 'EXPENSE'; + } +} \ No newline at end of file diff --git a/src/features/ai-agents/application/services/validation-agent.service.ts b/src/features/ai-agents/application/services/validation-agent.service.ts new file mode 100644 index 0000000..ae20b9d --- /dev/null +++ b/src/features/ai-agents/application/services/validation-agent.service.ts @@ -0,0 +1,92 @@ +import { AgentResponse } from "../../domain/entities/voice-command.entity"; + +export class ValidationAgentService { + async validateResponse(response: AgentResponse, userId: number): Promise { + if (!response.success) { + return response; + } + + const validationErrors: string[] = []; + const suggestedCorrections: Record = {}; + + switch (response.intent) { + case 'CREATE_TRANSACTION': + this.validateTransaction(response.extractedData, validationErrors, suggestedCorrections); + break; + case 'CREATE_GOAL': + this.validateGoal(response.extractedData, validationErrors, suggestedCorrections); + break; + case 'CREATE_BUDGET': + this.validateBudget(response.extractedData, validationErrors, suggestedCorrections); + break; + } + + return { + ...response, + validationErrors: validationErrors.length > 0 ? validationErrors : undefined, + suggestedCorrections: Object.keys(suggestedCorrections).length > 0 ? suggestedCorrections : undefined, + success: validationErrors.length === 0, + message: validationErrors.length > 0 + ? `Se encontraron algunos problemas: ${validationErrors.join(', ')}` + : response.message + }; + } + + private validateTransaction(data: Record, errors: string[], corrections: Record) { + if (!data.amount || data.amount <= 0) { + errors.push("El monto debe ser mayor a 0"); + corrections.amount = 0; + } + + if (!data.type || !['INCOME', 'EXPENSE'].includes(data.type)) { + errors.push("Tipo de transacción inválido"); + corrections.type = 'EXPENSE'; + } + + if (!data.description || data.description.trim() === '') { + errors.push("La descripción no puede estar vacía"); + corrections.description = 'Transacción sin descripción'; + } + } + + private validateGoal(data: Record, errors: string[], corrections: Record) { + if (!data.name || data.name.trim() === '') { + errors.push("El nombre de la meta no puede estar vacío"); + corrections.name = 'Meta sin nombre'; + } + + if (!data.target_amount || data.target_amount <= 0) { + errors.push("El monto objetivo debe ser mayor a 0"); + corrections.target_amount = 1000; + } + + if (data.current_amount < 0) { + errors.push("El monto actual no puede ser negativo"); + corrections.current_amount = 0; + } + + if (!data.end_date) { + errors.push("La fecha objetivo es requerida"); + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + corrections.end_date = futureDate.toISOString(); + } + } + + private validateBudget(data: Record, errors: string[], corrections: Record) { + if (!data.limit_amount || data.limit_amount <= 0) { + errors.push("El límite del presupuesto debe ser mayor a 0"); + corrections.limit_amount = 1000; + } + + if (!data.category_id) { + errors.push("Se requiere una categoría para el presupuesto"); + } + + if (!data.month) { + errors.push("Se requiere especificar el mes del presupuesto"); + const currentDate = new Date(); + corrections.month = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1).toISOString(); + } + } +} \ No newline at end of file diff --git a/src/features/ai-agents/application/services/voice-orchestrator.service.ts b/src/features/ai-agents/application/services/voice-orchestrator.service.ts new file mode 100644 index 0000000..a078a3d --- /dev/null +++ b/src/features/ai-agents/application/services/voice-orchestrator.service.ts @@ -0,0 +1,90 @@ +import { VoiceCommand, AgentResponse, CommandIntent } from "../../domain/entities/voice-command.entity"; +import { ITranscriptionService, ILLMService } from "../../domain/ports/transcription.port"; +import { TransactionAgentService } from "./transaction-agent.service"; +import { GoalAgentService } from "./goal-agent.service"; +import { BudgetAgentService } from "./budget-agent.service"; +import { ValidationAgentService } from "./validation-agent.service"; + +export class VoiceOrchestratorService { + constructor( + private transcriptionService: ITranscriptionService, + private llmService: ILLMService, + private transactionAgent: TransactionAgentService, + private goalAgent: GoalAgentService, + private budgetAgent: BudgetAgentService, + private validationAgent: ValidationAgentService + ) {} + + async processVoiceCommand(audioBlob: Blob, userId: number): Promise { + try { + const transcription = await this.transcriptionService.transcribe(audioBlob); + + const intentResult = await this.llmService.classifyIntent(transcription); + const intent = intentResult.intent as CommandIntent; + + if (intentResult.confidence < 0.7) { + return { + success: false, + intent: 'UNKNOWN', + extractedData: {}, + confidence: intentResult.confidence, + message: "No pude entender tu solicitud. ¿Podrías repetirla de otra manera?" + }; + } + + const extractionResult = await this.llmService.extractData(transcription, intent); + + let agentResponse: AgentResponse; + + switch (intent) { + case 'CREATE_TRANSACTION': + agentResponse = await this.transactionAgent.processTransaction( + extractionResult.data, + userId + ); + break; + case 'CREATE_GOAL': + agentResponse = await this.goalAgent.processGoal( + extractionResult.data, + userId + ); + break; + case 'CREATE_BUDGET': + agentResponse = await this.budgetAgent.processBudget( + extractionResult.data, + userId + ); + break; + default: + return { + success: false, + intent: 'UNKNOWN', + extractedData: {}, + confidence: 0, + message: "Tipo de comando no soportado" + }; + } + + const validatedResponse = await this.validationAgent.validateResponse( + agentResponse, + userId + ); + + return { + ...validatedResponse, + intent, + confidence: Math.min(intentResult.confidence, extractionResult.confidence) + }; + + } catch (error) { + console.error('Error processing voice command:', error); + return { + success: false, + intent: 'UNKNOWN', + extractedData: {}, + confidence: 0, + message: "Ocurrió un error procesando tu solicitud" + }; + } + } +} \ No newline at end of file diff --git a/src/features/ai-agents/application/use-cases/process-voice-command.use-case.ts b/src/features/ai-agents/application/use-cases/process-voice-command.use-case.ts new file mode 100644 index 0000000..05300c1 --- /dev/null +++ b/src/features/ai-agents/application/use-cases/process-voice-command.use-case.ts @@ -0,0 +1,50 @@ +import { VoiceOrchestratorService } from "../services/voice-orchestrator.service"; +import { AgentResponse } from "../../domain/entities/voice-command.entity"; + +export interface ProcessVoiceCommandRequest { + audioBlob: Blob; + userId: number; +} + +export class ProcessVoiceCommandUseCase { + constructor( + private voiceOrchestrator: VoiceOrchestratorService + ) {} + + async execute(request: ProcessVoiceCommandRequest): Promise { + const { audioBlob, userId } = request; + + if (!audioBlob || audioBlob.size === 0) { + return { + success: false, + intent: 'UNKNOWN', + extractedData: {}, + confidence: 0, + message: "No se recibió audio válido" + }; + } + + if (!userId || userId <= 0) { + return { + success: false, + intent: 'UNKNOWN', + extractedData: {}, + confidence: 0, + message: "Usuario no válido" + }; + } + + try { + return await this.voiceOrchestrator.processVoiceCommand(audioBlob, userId); + } catch (error) { + console.error('Error in ProcessVoiceCommandUseCase:', error); + return { + success: false, + intent: 'UNKNOWN', + extractedData: {}, + confidence: 0, + message: "Error interno del servidor" + }; + } + } +} \ No newline at end of file diff --git a/src/features/ai-agents/domain/entities/voice-command.entity.ts b/src/features/ai-agents/domain/entities/voice-command.entity.ts new file mode 100644 index 0000000..6d3061a --- /dev/null +++ b/src/features/ai-agents/domain/entities/voice-command.entity.ts @@ -0,0 +1,36 @@ +export interface VoiceCommand { + id: string; + userId: number; + audioBlob?: Blob; + transcription: string; + intent: CommandIntent; + extractedData: Record; + confidence: number; + status: CommandStatus; + createdAt: Date; + processedAt?: Date; +} + +export type CommandIntent = + | 'CREATE_TRANSACTION' + | 'CREATE_GOAL' + | 'CREATE_BUDGET' + | 'UNKNOWN'; + +export type CommandStatus = + | 'PENDING' + | 'PROCESSING' + | 'VALIDATED' + | 'CONFIRMED' + | 'EXECUTED' + | 'FAILED'; + +export interface AgentResponse { + success: boolean; + intent: CommandIntent; + extractedData: Record; + confidence: number; + validationErrors?: string[]; + suggestedCorrections?: Record; + message: string; +} \ No newline at end of file diff --git a/src/features/ai-agents/domain/ports/transcription.port.ts b/src/features/ai-agents/domain/ports/transcription.port.ts new file mode 100644 index 0000000..2c51ac9 --- /dev/null +++ b/src/features/ai-agents/domain/ports/transcription.port.ts @@ -0,0 +1,15 @@ +export interface ITranscriptionService { + transcribe(audioBlob: Blob): Promise; +} + +export interface ILLMService { + classifyIntent(transcription: string): Promise<{ + intent: string; + confidence: number; + }>; + + extractData(transcription: string, intent: string): Promise<{ + data: Record; + confidence: number; + }>; +} \ No newline at end of file diff --git a/src/features/ai-agents/infrastructure/adapters/openai-llm.adapter.ts b/src/features/ai-agents/infrastructure/adapters/openai-llm.adapter.ts new file mode 100644 index 0000000..0b74053 --- /dev/null +++ b/src/features/ai-agents/infrastructure/adapters/openai-llm.adapter.ts @@ -0,0 +1,135 @@ +import { ILLMService } from "../../domain/ports/transcription.port"; + +export class OpenAILLMAdapter implements ILLMService { + private apiKey: string; + private apiUrl = 'https://api.openai.com/v1/completions'; + + constructor(apiKey: string) { + this.apiKey = apiKey; + } + + async classifyIntent(transcription: string): Promise<{ intent: string; confidence: number }> { + const prompt = ` +Analiza el siguiente texto y determina la intención del usuario. Las opciones son: +- CREATE_TRANSACTION: Para crear gastos o ingresos +- CREATE_GOAL: Para crear metas de ahorro +- CREATE_BUDGET: Para crear presupuestos +- UNKNOWN: Si no es claro + +Texto: "${transcription}" + +Responde SOLO con un JSON en este formato: +{"intent": "CREATE_TRANSACTION", "confidence": 0.95} +`; + + try { + const response = await this.callOpenAI(prompt); + const result = JSON.parse(response); + return { + intent: result.intent || 'UNKNOWN', + confidence: result.confidence || 0.5 + }; + } catch (error) { + console.error('Error classifying intent:', error); + return { intent: 'UNKNOWN', confidence: 0 }; + } + } + + async extractData(transcription: string, intent: string): Promise<{ data: Record; confidence: number }> { + let prompt = 'category_id=1,comida;2,viajes;3,education;4,otro\n'; + + switch (intent) { + case 'CREATE_TRANSACTION': + prompt = ` +Extrae los datos de transacción del siguiente texto: +"${transcription}" + +Responde SOLO con un JSON con estos campos: +{ + "amount": número, + "type": "INCOME" o "EXPENSE", + "description": "descripción", + "category_id": "nombre de categoría si se menciona", + "payment_method": "método de pago si se menciona", + "confidence": 0.0-1.0 +} +`; + break; + + case 'CREATE_GOAL': + prompt = ` +Extrae los datos de meta de ahorro del siguiente texto: +"${transcription}" + +Responde SOLO con un JSON con estos campos: +{ + "name": "nombre de la meta", + "target_amount": número, + "end_date": "fecha en formato ISO si se menciona", + "contribution_amount": número si se menciona, + "confidence": 0.0-1.0 +} +`; + break; + + case 'CREATE_BUDGET': + prompt = ` +Extrae los datos de presupuesto del siguiente texto: +"${transcription}" + +Responde SOLO con un JSON con estos campos: +{ + "limit_amount": número, + "category": "nombre de categoría", + "month": "mes/año si se menciona", + "confidence": 0.0-1.0, + "category_id": id de la categoria segun corresponda +} +`; + break; + + default: + return { data: {}, confidence: 0 }; + } + + try { + const response = await this.callOpenAI(prompt); + const result = JSON.parse(response); + const confidence = result.confidence || 0.8; + delete result.confidence; + + return { + data: result, + confidence + }; + } catch (error) { + console.error('Error extracting data:', error); + return { data: {}, confidence: 0 }; + } + } + + private async callOpenAI(prompt: string): Promise { + const fullPrompt = `Eres un asistente especializado en finanzas personales. Responde siempre con JSON válido.\n\n${prompt}`; + + const response = await fetch(this.apiUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-3.5-turbo-instruct', + prompt: fullPrompt, + temperature: 0.3, + max_tokens: 400 + }), + }); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + return result.choices[0]?.text?.trim() || '{}'; + } +} \ No newline at end of file diff --git a/src/features/ai-agents/infrastructure/adapters/openai-transcription.adapter.ts b/src/features/ai-agents/infrastructure/adapters/openai-transcription.adapter.ts new file mode 100644 index 0000000..f6a977a --- /dev/null +++ b/src/features/ai-agents/infrastructure/adapters/openai-transcription.adapter.ts @@ -0,0 +1,38 @@ +import { ITranscriptionService } from "../../domain/ports/transcription.port"; + +export class OpenAITranscriptionAdapter implements ITranscriptionService { + private apiKey: string; + private apiUrl = 'https://api.openai.com/v1/audio/transcriptions'; + + constructor(apiKey: string) { + this.apiKey = apiKey; + } + + async transcribe(audioBlob: Blob): Promise { + try { + const formData = new FormData(); + formData.append('file', audioBlob, 'audio.wav'); + formData.append('model', 'whisper-1'); + formData.append('language', 'es'); + formData.append('response_format', 'json'); + + const response = await fetch(this.apiUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: formData, + }); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + return result.text || ''; + } catch (error) { + console.error('Error transcribing audio:', error); + throw new Error('Failed to transcribe audio'); + } + } +} \ No newline at end of file diff --git a/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts b/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts new file mode 100644 index 0000000..97b6eb1 --- /dev/null +++ b/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts @@ -0,0 +1,111 @@ +import { Hono } from "hono"; +import { ProcessVoiceCommandUseCase } from "../../application/use-cases/process-voice-command.use-case"; +import { VoiceOrchestratorService } from "../../application/services/voice-orchestrator.service"; +import { TransactionAgentService } from "../../application/services/transaction-agent.service"; +import { GoalAgentService } from "../../application/services/goal-agent.service"; +import { BudgetAgentService } from "../../application/services/budget-agent.service"; +import { ValidationAgentService } from "../../application/services/validation-agent.service"; +import { OpenAITranscriptionAdapter } from "../adapters/openai-transcription.adapter"; +import { OpenAILLMAdapter } from "../adapters/openai-llm.adapter"; +import { verifyToken } from "@/shared/utils/jwt.util"; + +const app = new Hono(); + +const OPENAI_API_KEY = process.env.OPENAI_API_KEY || ''; + +const transcriptionService = new OpenAITranscriptionAdapter(OPENAI_API_KEY); +const llmService = new OpenAILLMAdapter(OPENAI_API_KEY); +const transactionAgent = new TransactionAgentService(); +const goalAgent = new GoalAgentService(); +const budgetAgent = new BudgetAgentService(); +const validationAgent = new ValidationAgentService(); + +const voiceOrchestrator = new VoiceOrchestratorService( + transcriptionService, + llmService, + transactionAgent, + goalAgent, + budgetAgent, + validationAgent +); + +const processVoiceCommandUseCase = new ProcessVoiceCommandUseCase(voiceOrchestrator); + +app.post("/voice-command", async (c) => { + try { + const authHeader = c.req.header('Authorization'); + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return c.json({ + success: false, + message: "Token de autorización requerido" + }, 401); + } + + const token = authHeader.substring(7); + const payload = await verifyToken(token); + + if (!payload) { + return c.json({ + success: false, + message: "Token inválido" + }, 401); + } + + return c.json({ + "success": true, + "intent": "CREATE_GOAL", + "extractedData": { + "user_id": 1, + "name": "meta de ahorro para comida", + "target_amount": 600, + "current_amount": 0, + "end_date": "2026-10-09T21:58:33.460Z", + "category_id": null, + "contribution_frequency": null, + "contribution_amount": null + }, + "confidence": 0.95, + "message": "He identificado una meta de ahorro: \"meta de ahorro para comida\" con objetivo de $600" +}); + + // const user = payload as { id: number; email: string }; + // const body = await c.req?.formData(); + // const audioFile = body?.get("audio") as File; + + // if (!audioFile) { + // return c.json({ + // success: false, + // message: "No se recibió archivo de audio" + // }, 400); + // } + + // const audioBlob = new Blob([await audioFile.arrayBuffer()], { + // type: audioFile.type || 'audio/wav' + // }); + + // const result = await processVoiceCommandUseCase.execute({ + // audioBlob, + // userId: user.id + // }); + + // return c.json({ + // success: result.success, + // intent: result.intent, + // extractedData: result.extractedData, + // confidence: result.confidence, + // message: result.message, + // validationErrors: result.validationErrors, + // suggestedCorrections: result.suggestedCorrections + // }); + + } catch (error) { + console.error("Error processing voice command:", error); + return c.json({ + success: false, + message: "Error interno del servidor" + }, 500); + } +}); + +export default app; \ No newline at end of file