Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 . .
Expand Down
Binary file modified bun.lockb
Binary file not shown.
4 changes: 2 additions & 2 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,4 +32,4 @@ services:
restart: unless-stopped

volumes:
postgres_data:
postgres_data:
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -109,6 +110,7 @@ const routes = [
notifications,
email,
reports,
aiAgents,
notificationSocket,
] as const;

Expand Down
92 changes: 92 additions & 0 deletions src/features/ai-agents/README.md
Original file line number Diff line number Diff line change
@@ -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 <token>
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`
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { AgentResponse } from "../../domain/entities/voice-command.entity";

export class BudgetAgentService {
async processBudget(extractedData: Record<string, any>, userId: number): Promise<AgentResponse> {
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<string, any>, 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();
}
}
70 changes: 70 additions & 0 deletions src/features/ai-agents/application/services/goal-agent.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { AgentResponse } from "../../domain/entities/voice-command.entity";

export class GoalAgentService {
async processGoal(extractedData: Record<string, any>, userId: number): Promise<AgentResponse> {
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<string, any>, 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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { AgentResponse } from "../../domain/entities/voice-command.entity";

export class TransactionAgentService {
async processTransaction(extractedData: Record<string, any>, userId: number): Promise<AgentResponse> {
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<string, any>, 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<string, any>): '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';
}
}
Loading