Patrones para generar insights financieros inteligentes y sugerencias de ahorro contextuales. Incluye clasificación de gastos evitables vs necesarios, detección de oportunidades de ahorro, y mensajes personalizados. Usar cuando se trabaje con recomendaciones al usuario o análisis de hábitos de gasto.
74
Does it follow best practices?
If you maintain this skill, you can automatically optimize it using the tessl CLI to improve its score:
npx tessl skill review --optimize ./path/to/skillValidation for skill structure
Conocimiento especializado para generar insights financieros inteligentes y sugerencias de ahorro que respeten el contexto del usuario.
NO todas las sugerencias de ahorro son válidas. El sistema debe entender:
❌ NUNCA sugerir: "Deja de pagar el alquiler"
❌ NUNCA sugerir: "No compres comida"
✅ SÍ sugerir: "Estás gastando $200/mes en delivery, cocinar más ahorraría $120"
✅ SÍ sugerir: "Tu suscripción de gym ($50/mes) no se ha usado en 2 meses"enum Reducibility {
untouchable, // NO sugerir reducir NUNCA
difficult, // Sugerir con mucho cuidado
moderate, // Sugerir con contexto
easy, // Sugerir libremente
}
final Map<String, Map<String, Reducibility>> reducibilityMatrix = {
// GASTOS FIJOS - Generalmente intocables
'vivienda': {
'renta': Reducibility.untouchable,
'hipoteca': Reducibility.untouchable,
'mantenimiento': Reducibility.difficult,
'impuestos_prediales': Reducibility.untouchable,
},
'servicios': {
'electricidad': Reducibility.moderate, // "Apaga luces" es válido
'agua': Reducibility.moderate,
'gas': Reducibility.moderate,
'internet': Reducibility.difficult, // Necesario para trabajo
'telefono': Reducibility.moderate,
},
'seguros': {
'seguro_salud': Reducibility.untouchable,
'seguro_auto': Reducibility.difficult, // Legal en muchos países
'seguro_vida': Reducibility.moderate,
'seguro_hogar': Reducibility.moderate,
},
'deudas': {
'prestamo_personal': Reducibility.untouchable, // Obligación legal
'tarjeta_credito': Reducibility.untouchable,
'prestamo_auto': Reducibility.untouchable,
'prestamo_estudiantil': Reducibility.untouchable,
},
// GASTOS VARIABLES - Más flexibles
'alimentacion': {
'supermercado': Reducibility.difficult, // Necesario, pero optimizable
'restaurantes': Reducibility.easy, // ← Oportunidad de ahorro
'cafeterias': Reducibility.easy, // ← Oportunidad de ahorro
'delivery': Reducibility.easy, // ← Oportunidad de ahorro
},
'transporte': {
'gasolina': Reducibility.moderate, // Depende de si hay alternativas
'uber_taxi': Reducibility.easy, // ← Oportunidad de ahorro
'transporte_publico': Reducibility.difficult,
'estacionamiento': Reducibility.moderate,
},
'entretenimiento': {
'cine': Reducibility.easy,
'eventos': Reducibility.easy,
'hobbies': Reducibility.easy,
'viajes': Reducibility.easy,
},
'suscripciones': {
'streaming': Reducibility.easy, // ← Oportunidad de ahorro
'software': Reducibility.moderate, // Puede ser necesario para trabajo
'gimnasio': Reducibility.easy, // ← Si no se usa
'membresias': Reducibility.easy,
},
'compras': {
'ropa': Reducibility.easy,
'electronicos': Reducibility.easy,
'hogar': Reducibility.moderate,
'cuidado_personal': Reducibility.moderate,
},
};enum InsightType {
warning, // Alerta de gasto excesivo
opportunity, // Oportunidad de ahorro
pattern, // Patrón detectado
achievement, // Logro positivo
comparison, // Comparación temporal
projection, // Proyección futura
}
enum InsightPriority {
high, // Mostrar inmediatamente
medium, // Mostrar en resumen semanal
low, // Mostrar si el usuario pregunta
}
class FinancialInsight {
final InsightType type;
final InsightPriority priority;
final String title;
final String message;
final String? actionableAdvice;
final double? potentialSavings;
final String? relatedCategoryId;
final DateTime generatedAt;
const FinancialInsight({
required this.type,
required this.priority,
required this.title,
required this.message,
this.actionableAdvice,
this.potentialSavings,
this.relatedCategoryId,
required this.generatedAt,
});
}class InsightGenerator {
/// Genera insights basados en transacciones del período
static List<FinancialInsight> generate({
required List<Transaction> transactions,
required List<Budget> budgets,
required Map<String, Category> categories,
required HistoricalData history,
}) {
final insights = <FinancialInsight>[];
// 1. Análisis de subcategorías
insights.addAll(_analyzeSubcategories(transactions, categories));
// 2. Comparación con mes anterior
insights.addAll(_compareWithPrevious(transactions, history));
// 3. Detección de gastos inusuales
insights.addAll(_detectUnusualSpending(transactions, history));
// 4. Análisis de suscripciones no usadas
insights.addAll(_analyzeUnusedSubscriptions(transactions));
// 5. Oportunidades de ahorro fácil
insights.addAll(_findEasySavings(transactions, categories));
return insights..sort((a, b) => a.priority.index.compareTo(b.priority.index));
}
/// Analiza subcategorías para encontrar concentración de gastos
static List<FinancialInsight> _analyzeSubcategories(
List<Transaction> transactions,
Map<String, Category> categories,
) {
final insights = <FinancialInsight>[];
final bySubcategory = _groupBySubcategory(transactions);
for (final entry in bySubcategory.entries) {
final subcatId = entry.key;
final total = entry.value;
final categoryId = _getCategoryForSubcategory(subcatId, categories);
final categoryTotal = _getCategoryTotal(categoryId, transactions);
// Si una subcategoría es >40% de su categoría padre
if (categoryTotal > 0 && (total / categoryTotal) > 0.4) {
final reducibility = _getReducibility(categoryId, subcatId);
if (reducibility == Reducibility.easy) {
final subcatName = _getSubcategoryName(subcatId, categories);
final potentialSaving = total * 0.3; // Sugerir reducir 30%
insights.add(FinancialInsight(
type: InsightType.opportunity,
priority: InsightPriority.high,
title: 'Oportunidad de ahorro en $subcatName',
message: 'Gastaste \$${total.toStringAsFixed(0)} en $subcatName este mes, '
'que representa ${((total/categoryTotal)*100).toStringAsFixed(0)}% '
'de tu gasto en ${_getCategoryName(categoryId, categories)}.',
actionableAdvice: 'Si reduces este gasto un 30%, ahorrarías '
'\$${potentialSaving.toStringAsFixed(0)} al mes.',
potentialSavings: potentialSaving,
relatedCategoryId: categoryId,
generatedAt: DateTime.now(),
));
}
}
}
return insights;
}
/// Compara con el mes anterior
static List<FinancialInsight> _compareWithPrevious(
List<Transaction> current,
HistoricalData history,
) {
final insights = <FinancialInsight>[];
final currentTotal = _totalExpenses(current);
final previousTotal = history.previousMonthExpenses;
if (previousTotal > 0) {
final change = ((currentTotal - previousTotal) / previousTotal) * 100;
if (change > 20) {
insights.add(FinancialInsight(
type: InsightType.warning,
priority: InsightPriority.high,
title: 'Gastos aumentaron ${change.toStringAsFixed(0)}%',
message: 'Este mes has gastado \$${currentTotal.toStringAsFixed(0)}, '
'comparado con \$${previousTotal.toStringAsFixed(0)} el mes pasado.',
actionableAdvice: 'Revisa en qué categorías aumentó el gasto.',
generatedAt: DateTime.now(),
));
} else if (change < -10) {
insights.add(FinancialInsight(
type: InsightType.achievement,
priority: InsightPriority.medium,
title: '¡Redujiste gastos ${change.abs().toStringAsFixed(0)}%!',
message: 'Este mes gastaste \$${currentTotal.toStringAsFixed(0)}, '
'\$${(previousTotal - currentTotal).toStringAsFixed(0)} menos que el mes pasado.',
generatedAt: DateTime.now(),
));
}
}
return insights;
}
/// Detecta gastos inusualmente altos
static List<FinancialInsight> _detectUnusualSpending(
List<Transaction> transactions,
HistoricalData history,
) {
final insights = <FinancialInsight>[];
final byCategory = _groupByCategory(transactions);
for (final entry in byCategory.entries) {
final categoryId = entry.key;
final currentTotal = entry.value;
final average = history.getCategoryAverage(categoryId);
// Si es más del doble del promedio
if (average > 0 && currentTotal > average * 2) {
insights.add(FinancialInsight(
type: InsightType.warning,
priority: InsightPriority.high,
title: 'Gasto inusual detectado',
message: 'Tu gasto en ${_getCategoryName(categoryId)} este mes '
'(\$${currentTotal.toStringAsFixed(0)}) es el doble de tu promedio '
'(\$${average.toStringAsFixed(0)}).',
actionableAdvice: 'Revisa si hay algún gasto extraordinario o recurrente nuevo.',
relatedCategoryId: categoryId,
generatedAt: DateTime.now(),
));
}
}
return insights;
}
/// Encuentra oportunidades de ahorro fácil
static List<FinancialInsight> _findEasySavings(
List<Transaction> transactions,
Map<String, Category> categories,
) {
final insights = <FinancialInsight>[];
final easyToReduce = <String, double>{};
for (final t in transactions.where((t) => t.tipo == TransactionType.egreso)) {
final reducibility = _getReducibility(t.categoriaId, t.subcategoriaId);
if (reducibility == Reducibility.easy) {
final key = '${t.categoriaId}:${t.subcategoriaId ?? ""}';
easyToReduce[key] = (easyToReduce[key] ?? 0) + t.monto;
}
}
// Ordenar por monto y sugerir los top 3
final sorted = easyToReduce.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final top3 = sorted.take(3);
double totalPotential = 0;
for (final entry in top3) {
totalPotential += entry.value * 0.25; // Sugerir reducir 25%
}
if (totalPotential > 50) { // Solo si vale la pena
insights.add(FinancialInsight(
type: InsightType.opportunity,
priority: InsightPriority.medium,
title: 'Podrías ahorrar \$${totalPotential.toStringAsFixed(0)}/mes',
message: 'Identificamos ${top3.length} categorías donde podrías reducir '
'gastos sin afectar tu calidad de vida.',
actionableAdvice: 'Enfócate en: ${top3.map((e) => e.key.split(":")[0]).join(", ")}',
potentialSavings: totalPotential,
generatedAt: DateTime.now(),
));
}
return insights;
}
}final achievementTemplates = [
'¡Excelente! Gastaste {amount} menos que el mes pasado en {category}.',
'¡Felicitaciones! Tu ahorro aumentó {percent}% este mes.',
'Llevas {days} días sin gastar en {category}. ¡Sigue así!',
'Tu balance es positivo por {amount}. Considera invertir el excedente.',
];final opportunityTemplates = [
'Gastas {amount}/mes en {subcategory}. Si cocinas {times} veces más por semana, ahorrarías {savings}.',
'Tu gasto en {category} representa {percent}% de tus ingresos. El promedio recomendado es {recommended}%.',
'{subscription} te cuesta {amount}/mes. ¿Lo usas regularmente?',
'Detectamos que compras {item} frecuentemente. Considera comprar al mayoreo.',
];final warningTemplates = [
'Tu gasto en {category} aumentó {percent}% respecto al mes pasado.',
'Estás gastando {amount} más de lo presupuestado en {category}.',
'A este ritmo, terminarás el mes con un balance de {projected}.',
'Llevas {count} transacciones en {category} esta semana, más que tu promedio.',
];class InsightFrequencyRules {
// No repetir el mismo insight en menos de X días
static const Map<InsightType, int> cooldownDays = {
InsightType.warning: 3, // Advertencias cada 3 días máximo
InsightType.opportunity: 7, // Oportunidades cada semana
InsightType.pattern: 14, // Patrones cada 2 semanas
InsightType.achievement: 1, // Logros pueden ser diarios
InsightType.comparison: 7, // Comparaciones semanales
InsightType.projection: 3, // Proyecciones cada 3 días
};
// Máximo de insights por día
static const int maxInsightsPerDay = 3;
// Priorizar insights con mayor potencial de ahorro
static const double minSavingsToShow = 20.0; // Mínimo $20 de ahorro potencial
}class InsightCard extends StatelessWidget {
final FinancialInsight insight;
final VoidCallback? onDismiss;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Card(
color: _getBackgroundColor(insight.type),
child: ListTile(
leading: Icon(_getIcon(insight.type), color: _getIconColor(insight.type)),
title: Text(insight.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(insight.message),
if (insight.actionableAdvice != null)
Text(
insight.actionableAdvice!,
style: TextStyle(fontWeight: FontWeight.bold),
),
if (insight.potentialSavings != null)
Chip(
label: Text('Ahorro: \$${insight.potentialSavings!.toStringAsFixed(0)}/mes'),
backgroundColor: Colors.green.shade100,
),
],
),
trailing: IconButton(
icon: Icon(Icons.close),
onPressed: onDismiss,
),
),
);
}
Color _getBackgroundColor(InsightType type) {
switch (type) {
case InsightType.warning: return Colors.orange.shade50;
case InsightType.opportunity: return Colors.blue.shade50;
case InsightType.achievement: return Colors.green.shade50;
case InsightType.pattern: return Colors.purple.shade50;
default: return Colors.grey.shade50;
}
}
IconData _getIcon(InsightType type) {
switch (type) {
case InsightType.warning: return Icons.warning_amber;
case InsightType.opportunity: return Icons.lightbulb_outline;
case InsightType.achievement: return Icons.emoji_events;
case InsightType.pattern: return Icons.trending_up;
case InsightType.comparison: return Icons.compare_arrows;
case InsightType.projection: return Icons.timeline;
}
}
}{
"type": "opportunity",
"priority": "high",
"title": "Oportunidad de ahorro en Delivery",
"message": "Gastaste $320 en Delivery este mes, que representa 65% de tu gasto en Alimentación.",
"actionableAdvice": "Si reduces este gasto un 30%, ahorrarías $96 al mes. Considera cocinar 2 días más por semana.",
"potentialSavings": 96.0,
"relatedCategoryId": "alimentacion"
}{
"type": "opportunity",
"priority": "medium",
"title": "¿Usas tu suscripción de Gimnasio?",
"message": "Pagas $50/mes por el gimnasio pero no hemos detectado gastos relacionados (estacionamiento, suplementos, ropa deportiva) en los últimos 60 días.",
"actionableAdvice": "Si no lo usas, cancelar te ahorraría $600 al año.",
"potentialSavings": 50.0
}{
"type": "achievement",
"priority": "medium",
"title": "¡Redujiste gastos en Entretenimiento!",
"message": "Este mes gastaste $80 en Entretenimiento, $120 menos que tu promedio de $200.",
"actionableAdvice": null,
"potentialSavings": null
}1e99f86
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.