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
Binary file added bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion drizzle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ if (process.env.DATABASE_URL) {

const isTestEnv = process.env.NODE_ENV === "test";
const connectionString =
(isTestEnv && process.env.TEST_DATABASE_URL) || env.DATABASE_URL;
(isTestEnv && process.env.TEST_DATABASE_URL) || process.env.DATABASE_URL;

if (isTestEnv && !process.env.TEST_DATABASE_URL) {
console.warn(
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@getbrevo/brevo": "^2.2.0",
"@hono/zod-openapi": "^0.18.3",
"@langchain/core": "^0.3.77",
"@openrouter/sdk": "^0.1.27",
"@scalar/hono-api-reference": "^0.5.162",
"@types/json2csv": "^5.0.7",
"@types/uuid": "^10.0.0",
Expand Down
3 changes: 2 additions & 1 deletion src/core/infrastructure/cron/daily-recommendations.cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const orchestrator = RecommendationOrchestratorService.getInstance(
);

const dailyRecommendationsJob = new cron.CronJob(
"0 6 * * *",
// "0 6 * * *",
"*/5 * * * *", // TODO: Change back to "0 6 * * *" for production (runs every 5 minutes for testing)
async () => {
console.log("Running daily recommendations job...");

Expand Down
2 changes: 0 additions & 2 deletions src/core/infrastructure/scripts/plans.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ async function seedPlans() {
"Entrada de voz",
"Acceso ilimitado a IA",
"Soporte básico",
"Acceso a la app móvil",
"Informes exportables"
]
},
Expand All @@ -47,7 +46,6 @@ async function seedPlans() {
"Entrada de voz",
"Acceso ilimitado a IA",
"Soporte básico",
"Acceso a la app móvil",
"Informes exportables"
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { ILLMService } from "../../domain/ports/transcription.port";

export class OpenAILLMAdapter implements ILLMService {
private apiKey: string;
private apiUrl = 'https://api.openai.com/v1/completions';
private apiUrl = "https://api.openai.com/v1/completions";

constructor(apiKey: string) {
this.apiKey = apiKey;
}

async classifyIntent(transcription: string): Promise<{ intent: string; confidence: number }> {
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
Expand All @@ -26,20 +28,23 @@ Responde SOLO con un JSON en este formato:
const response = await this.callOpenAI(prompt);
const result = JSON.parse(response);
return {
intent: result.intent || 'UNKNOWN',
confidence: result.confidence || 0.5
intent: result.intent || "UNKNOWN",
confidence: result.confidence || 0.5,
};
} catch (error) {
console.error('Error classifying intent:', error);
return { intent: 'UNKNOWN', confidence: 0 };
console.error("Error classifying intent:", error);
return { intent: "UNKNOWN", confidence: 0 };
}
}

async extractData(transcription: string, intent: string): Promise<{ data: Record<string, any>; confidence: number }> {
let prompt = 'category_id=1,comida;2,viajes;3,education;4,otro\n';
async extractData(
transcription: string,
intent: string
): Promise<{ data: Record<string, any>; confidence: number }> {
let prompt = "category_id=1,comida;2,viajes;3,education;4,otro\n";

switch (intent) {
case 'CREATE_TRANSACTION':
case "CREATE_TRANSACTION":
prompt = `
Extrae los datos de transacción del siguiente texto:
"${transcription}"
Expand All @@ -56,7 +61,7 @@ Responde SOLO con un JSON con estos campos:
`;
break;

case 'CREATE_GOAL':
case "CREATE_GOAL":
prompt = `
Extrae los datos de meta de ahorro del siguiente texto:
"${transcription}"
Expand All @@ -72,7 +77,7 @@ Responde SOLO con un JSON con estos campos:
`;
break;

case 'CREATE_BUDGET':
case "CREATE_BUDGET":
prompt = `
Extrae los datos de presupuesto del siguiente texto:
"${transcription}"
Expand All @@ -97,39 +102,41 @@ Responde SOLO con un JSON con estos campos:
const result = JSON.parse(response);
const confidence = result.confidence || 0.8;
delete result.confidence;

return {
data: result,
confidence
confidence,
};
} catch (error) {
console.error('Error extracting data:', error);
console.error("Error extracting data:", error);
return { data: {}, confidence: 0 };
}
}

private async callOpenAI(prompt: string): Promise<string> {
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',
method: "POST",
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: 'gpt-3.5-turbo-instruct',
model: "gpt-4.1-nano",
prompt: fullPrompt,
temperature: 0.3,
max_tokens: 400
max_tokens: 400,
}),
});

if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
throw new Error(
`OpenAI API error: ${response.status} ${response.statusText}`
);
}

const result = await response.json();
return result.choices[0]?.text?.trim() || '{}';
return result.choices[0]?.text?.trim() || "{}";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { ILLMService } from "../../domain/ports/transcription.port";

export class OpenRouterLLMAdapter implements ILLMService {
private apiKey: string;
private apiUrl = 'https://openrouter.ai/api/v1/chat/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.callOpenRouter(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<string, any>; 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.callOpenRouter(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 callOpenRouter(prompt: string): Promise<string> {
const systemPrompt = 'Eres un asistente especializado en finanzas personales. Responde siempre con JSON válido.';

const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://foppy.app', // Optional: your app URL
'X-Title': 'Foppy Finance Assistant', // Optional: your app name
},
body: JSON.stringify({
model: 'x-ai/grok-4.1-fast:free', // Free Grok model from X.AI
messages: [
{
role: 'system',
content: systemPrompt
},
{
role: 'user',
content: prompt
}
],
temperature: 0.3,
max_tokens: 400
}),
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenRouter API error: ${response.status} ${response.statusText} - ${errorText}`);
}

const result = await response.json();
return result.choices[0]?.message?.content?.trim() || '{}';
}
}
Loading
Loading