From 00ab6214efc7fcf1175f14b791bb8c2c8596033c Mon Sep 17 00:00:00 2001 From: leninner Date: Sun, 28 Sep 2025 20:02:49 -0500 Subject: [PATCH 1/4] feat: add ai agents module --- Dockerfile | 2 +- bun.lockb | Bin 211003 -> 226677 bytes docker-compose.yaml | 4 +- package.json | 2 + src/app.ts | 2 + src/features/ai-agents/README.md | 92 ++++++++++++ .../services/budget-agent.service.ts | 65 +++++++++ .../services/goal-agent.service.ts | 70 +++++++++ .../services/transaction-agent.service.ts | 59 ++++++++ .../services/validation-agent.service.ts | 92 ++++++++++++ .../services/voice-orchestrator.service.ts | 90 ++++++++++++ .../process-voice-command.use-case.ts | 50 +++++++ .../domain/entities/voice-command.entity.ts | 36 +++++ .../domain/ports/transcription.port.ts | 15 ++ .../adapters/openai-llm.adapter.ts | 134 ++++++++++++++++++ .../adapters/openai-transcription.adapter.ts | 38 +++++ .../controllers/voice-command.controller.ts | 94 ++++++++++++ 17 files changed, 842 insertions(+), 3 deletions(-) create mode 100644 src/features/ai-agents/README.md create mode 100644 src/features/ai-agents/application/services/budget-agent.service.ts create mode 100644 src/features/ai-agents/application/services/goal-agent.service.ts create mode 100644 src/features/ai-agents/application/services/transaction-agent.service.ts create mode 100644 src/features/ai-agents/application/services/validation-agent.service.ts create mode 100644 src/features/ai-agents/application/services/voice-orchestrator.service.ts create mode 100644 src/features/ai-agents/application/use-cases/process-voice-command.use-case.ts create mode 100644 src/features/ai-agents/domain/entities/voice-command.entity.ts create mode 100644 src/features/ai-agents/domain/ports/transcription.port.ts create mode 100644 src/features/ai-agents/infrastructure/adapters/openai-llm.adapter.ts create mode 100644 src/features/ai-agents/infrastructure/adapters/openai-transcription.adapter.ts create mode 100644 src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts 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 daf88459e98d9badb78a5bf6bd2c10bbafc725a7..bb2bca560c07a293d2ec97d8ff8777601932ceba 100755 GIT binary patch delta 47365 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ˤG<;v0$HH#j2!F_hv{)DiDcoS?q`a4+<8~q_I55pd?+`2HPz_Cu+ zhO%W^Sno(1zP|UBEm=c+9Vq5%N6OqV9p{J^Ah>TcXWYHs=^9-I1V3>?xNZ&8r;t_h zJ`gj3=t|rC)uM+Ft`IVoSRMA1yLadY-V!^q=Zo2ye0677c+C!aXa}TcM|58I(XwZX zQ@s}_^X)D!E7?-y2z3HcQf?q#d?)-&M%K#DO~ic$rR1Jhe%ZHUpxLB2BlI`U+W?FOZ7@=skG(w8-vl->v}jzF`Y0Wk}_f0(kGD&OJS+Z6@XRC|%~7-3vq}G~1JHd*Ss<&h;juCQ7y_q&EX`6A;t0?z*(+V8icCM5jS{ zuG0N*uzX>!N!cHz7Vuh*0l{a#*ZR_Oh8JDB*F-$Hn|>Z;cTFqn)kFP>-maZ!EU61| z&B6f}>dt+)OZ!(}`Dectq6DQpLaR7$QWpw@6TBHv!**xShQZVunaSG_plDbgv z!iA@~^rS8nh#|v4v&Y8l_n$0LP3fZ%n%f25q%IV^F@yKknTv`hJ$a$e3hsNm(d^*$k-_n)9I zbt9ksI&kU01@AnyIVJSn$I+iT4r4)hA60al4KOT%JG!B4+I~G&_MPf}>_;2VxFNZe zAO6{}_SMIRaN+vx3y$1z8z>Gm>9Cd>N=`e@Kg@-nsuB8cH>*td1+c=$&kf&-y;Wd&mv5?@)f7@9?z^^+;B5SVm_fg*|M%YGXLq|mr>^QSnr7Sqs}>>I1Kvl z)I<5Hr{4a!%%ETRyV7xVdF|nEpv(un3!`g}t*+hXS&K5C(Vx!UdEJ&oGyibfZiWe$=AbtQ{#1mf9YX=`X+raN6Ow5uPl@cq1_)=E8^XHFv${uUCEi1NQ;H+o!2Paaa~`%REC668kEvEw2n$F0HnTxVS?7 zNR5;6%gLBEb@C>mqoy`#p@7G&L1M_I_-Z_AwGs>^NKhIROHGP9fmRpvKax;%`dw;J zO{nmDJc?IIsBnT_9aOl)P2*;&QPn_5RqGRAicfRl1>MesIxoH`A*gsJw(s}Xc~rSh z^(CurP=k2A|3}H>B)L(o=ElL%P${=gF;%5XIcpM{Lvjci9lW=*w$UG~SA&JTlBUm<=1q;)(Hl2Q**xVo$mTD;mKRN4 zTPb6iR!oP+SbiFa1_X6WtV`+e7|Y8|h!jv0N;02p9i*IKc~m-ttl<(`ElKm|nCb&> z7G%31ix3qd2x>eWbu-w78T`JG9}duzYWxiZMPOF&nA22`>gD;nLM7n7szz-J_~DtL z41YRR*{^v5;Pq?%t8k&|HT4Fj8m6YQ%dD9G6JrGnjr0k$GM;39rP#q$su2C5P#mk% zYXi)(GgnHbmw$zfKmMfS?#lNMuzb6bHV_g-l13Jp87?7!-sV;Pk7{ZIe-HFP=vqXF zceCvL2~}X8P=*pVTAd^1FNli}@(pp^d+>s2q1NqFJd{4naxw$5D0m(dSmT-AoFf%f zI+K|fu%z`e*6Q2}kRNh;1Mo+qGt}s=(J1^fv*b^#b3w{gHB?w46;RbhHiI@~NqZ@6 zsWgTD8DsW^@nGjoj|V>N#Rq(}#=k3sXRYYI0hXINp1%Ti{826z(Nvz$t0oLPfKDhQ zz<9VqH1C=?DzKYf(~%%VAKSpKKY$I}iK1~n9IPaC=~-8UG4nVm{cV;{9lO~S8ki(y zP38$DQu@GJ}`^ delta 38404 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 mFRs_{nJm^CK0*67bn#i^5VtG(ln`C3`E1 +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..ba3e876 --- /dev/null +++ b/src/features/ai-agents/infrastructure/adapters/openai-llm.adapter.ts @@ -0,0 +1,134 @@ +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 = ''; + + 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": "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 +} +`; + 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: 500 + }), + }); + + 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..70f3ef5 --- /dev/null +++ b/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts @@ -0,0 +1,94 @@ +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); + } + + 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 From df7c4d9df2ea0bba6ac548db93defaa453d6cce3 Mon Sep 17 00:00:00 2001 From: leninner Date: Sun, 28 Sep 2025 20:56:21 -0500 Subject: [PATCH 2/4] chore: update new endpoint to agentic foppy --- .../infrastructure/controllers/voice-command.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts b/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts index 70f3ef5..b024541 100644 --- a/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts +++ b/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts @@ -53,8 +53,8 @@ app.post("/voice-command", async (c) => { } const user = payload as { id: number; email: string }; - const body = await c.req.formData(); - const audioFile = body.get("audio") as File; + const body = await c.req?.formData(); + const audioFile = body?.get("audio") as File; if (!audioFile) { return c.json({ From 760ba34fa8e894f4f7898d64517db95e7dc37e02 Mon Sep 17 00:00:00 2001 From: leninner Date: Tue, 7 Oct 2025 09:55:02 -0500 Subject: [PATCH 3/4] chore: pause AI integration; --- .../controllers/voice-command.controller.ts | 66 ++++++++++++------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts b/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts index b024541..714b5e8 100644 --- a/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts +++ b/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts @@ -52,35 +52,51 @@ app.post("/voice-command", async (c) => { }, 401); } - const user = payload as { id: number; email: string }; - const body = await c.req?.formData(); - const audioFile = body?.get("audio") as File; + return c.json({ + "success": true, + "intent": "CREATE_TRANSACTION", + "extractedData": { + "user_id": 1, + "amount": 25, + "type": "EXPENSE", + "description": "desayuno", + "category_id": null, + "payment_method_id": null, + "date": "2025-10-07T13:18:32.623Z" + }, + "confidence": 0.95, + "message": "He identificado una transacción: Gasto de $25 en desayuno" +}); - if (!audioFile) { - return c.json({ - success: false, - message: "No se recibió archivo de audio" - }, 400); - } + // const user = payload as { id: number; email: string }; + // const body = await c.req?.formData(); + // const audioFile = body?.get("audio") as File; - const audioBlob = new Blob([await audioFile.arrayBuffer()], { - type: audioFile.type || 'audio/wav' - }); + // if (!audioFile) { + // return c.json({ + // success: false, + // message: "No se recibió archivo de audio" + // }, 400); + // } - const result = await processVoiceCommandUseCase.execute({ - audioBlob, - userId: user.id - }); + // const audioBlob = new Blob([await audioFile.arrayBuffer()], { + // type: audioFile.type || 'audio/wav' + // }); - return c.json({ - success: result.success, - intent: result.intent, - extractedData: result.extractedData, - confidence: result.confidence, - message: result.message, - validationErrors: result.validationErrors, - suggestedCorrections: result.suggestedCorrections - }); + // 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); From 8861e0d46618808999829690b4a0d00ba29b6cb2 Mon Sep 17 00:00:00 2001 From: leninner Date: Thu, 9 Oct 2025 17:15:35 -0500 Subject: [PATCH 4/4] chore: update --- .../adapters/openai-llm.adapter.ts | 9 +++++---- .../controllers/voice-command.controller.ts | 17 +++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/features/ai-agents/infrastructure/adapters/openai-llm.adapter.ts b/src/features/ai-agents/infrastructure/adapters/openai-llm.adapter.ts index ba3e876..0b74053 100644 --- a/src/features/ai-agents/infrastructure/adapters/openai-llm.adapter.ts +++ b/src/features/ai-agents/infrastructure/adapters/openai-llm.adapter.ts @@ -36,7 +36,7 @@ Responde SOLO con un JSON en este formato: } async extractData(transcription: string, intent: string): Promise<{ data: Record; confidence: number }> { - let prompt = ''; + let prompt = 'category_id=1,comida;2,viajes;3,education;4,otro\n'; switch (intent) { case 'CREATE_TRANSACTION': @@ -49,7 +49,7 @@ Responde SOLO con un JSON con estos campos: "amount": número, "type": "INCOME" o "EXPENSE", "description": "descripción", - "category": "nombre de categoría si se menciona", + "category_id": "nombre de categoría si se menciona", "payment_method": "método de pago si se menciona", "confidence": 0.0-1.0 } @@ -82,7 +82,8 @@ 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 + "confidence": 0.0-1.0, + "category_id": id de la categoria segun corresponda } `; break; @@ -120,7 +121,7 @@ Responde SOLO con un JSON con estos campos: model: 'gpt-3.5-turbo-instruct', prompt: fullPrompt, temperature: 0.3, - max_tokens: 500 + max_tokens: 400 }), }); diff --git a/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts b/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts index 714b5e8..97b6eb1 100644 --- a/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts +++ b/src/features/ai-agents/infrastructure/controllers/voice-command.controller.ts @@ -52,20 +52,21 @@ app.post("/voice-command", async (c) => { }, 401); } - return c.json({ + return c.json({ "success": true, - "intent": "CREATE_TRANSACTION", + "intent": "CREATE_GOAL", "extractedData": { "user_id": 1, - "amount": 25, - "type": "EXPENSE", - "description": "desayuno", + "name": "meta de ahorro para comida", + "target_amount": 600, + "current_amount": 0, + "end_date": "2026-10-09T21:58:33.460Z", "category_id": null, - "payment_method_id": null, - "date": "2025-10-07T13:18:32.623Z" + "contribution_frequency": null, + "contribution_amount": null }, "confidence": 0.95, - "message": "He identificado una transacción: Gasto de $25 en desayuno" + "message": "He identificado una meta de ahorro: \"meta de ahorro para comida\" con objetivo de $600" }); // const user = payload as { id: number; email: string };