{"repo": "arcay322/cashflow2", "files": {"src/services/deepseek.js": {"fcc09f9cc814b260219b75941583cdc048d48104": {"t": 1786068263.484698, "n": 17734, "h": "987b873f337940a2", "c": "/**\n * Cashflow IA - Motor de IA (DeepSeek + fallback local)\n *\n * El LLM \"entiende\" (clasifica intención y extrae entidades) y devuelve un JSON\n * estricto. La ejecución de acciones y los cálculos de consultas se hacen en el\n * cliente (deterministas, sin coste).\n */\n\nexport const DEFAULT_CATEGORIES = [\n \"Alimentación y Comida\",\n \"Transporte y Gasolina\",\n \"Servicios (Luz, Agua, Internet)\",\n \"Entretenimiento y Ocio\",\n \"Salud y Medicinas\",\n \"Educación y Cursos\",\n \"Hogar y Compras\",\n \"Ingreso (Sueldo/Trabajo)\",\n \"Otros\"\n];\n\nexport const CURRENCIES = ['S/.', '$', '€'];\n\nfunction formatMoney(n, currency) {\n const value = Number(n || 0);\n return `${currency} ${value.toLocaleString('es-PE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;\n}\n\n// ---------------------------------------------------------------- DeepSeek\nasync function callDeepSeek(messages, temperature = 0.1, response_format) {\n const apiKey = import.meta.env.VITE_DEEPSEEK_API_KEY;\n const proxyUrl = import.meta.env.VITE_DEEPSEEK_PROXY_URL || '/api/deepseek';\n\n const body = { model: 'deepseek-chat', messages, temperature };\n if (response_format) body.response_format = response_format;\n\n if (apiKey && apiKey !== 'sk-tu_api_key_de_deepseek') {\n const response = await fetch('https://api.deepseek.com/v1/chat/completions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey.trim()}` },\n body: JSON.stringify(body)\n });\n if (!response.ok) {\n const data = await response.json().catch(() => null);\n throw new Error(data?.error?.message || `DeepSeek API error (${response.status})`);\n }\n return response.json();\n }\n\n const response = await fetch(proxyUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body)\n });\n if (!response.ok) {\n const data = await response.json().catch(() => null);\n throw new Error(data?.error || `Proxy error (${response.status})`);\n }\n return response.json();\n}\n\nfunction SYSTEM_PROMPT(categories, currency) {\n return `\nEres el motor de comandos de la app de finanzas \"Cashflow IA\". Clasifica la intención\ndel mensaje del usuario (en español) y llena los campos correspondientes.\nDevuelve ÚNICAMENTE un objeto JSON válido, sin texto extra.\n\nINTENCIONES (campo \"intent\"):\n- \"register\": registrar una o más transacciones (gastos/ingresos).\n- \"query\": pregunta por cifras con filtro (cuánto gasté/gané este mes/semana/hoy, por categoría, comparativas).\n- \"action\": ejecutar una acción (borrar/editar, ajustar presupuesto, cambiar moneda, exportar CSV).\n- \"advice\": pide consejo/asesoría financiera abierta (cómo ahorrar, recomendación, qué hacer).\n- \"unknown\": no se entiende o conversación casual.\n\nSegún el intent llena el campo correspondiente:\n\n1. register -> \"transactions\": array de { \"type\":\"expense\"|\"income\", \"amount\":number,\n \"category\":\"nombre exacto de la lista\", \"description\":\"texto corto, mayúscula inicial\",\n \"date\":\"YYYY-MM-DD\" (fecha relativa: hoy/ayer/anteayer, default hoy),\n \"recurring\":bool (true si dice \"cada mes\", \"mensual\", \"cada semana\") }. Si menciona varias transacciones, devuelve varias.\n\n2. query -> \"query\": { \"kind\":\"expense\"|\"income\"|\"balance\"|\"comparison\"|\"category\",\n \"category\":string|null, \"period\":\"today\"|\"week\"|\"month\"|\"prev_month\"|\"prev_week\"|\"all\",\n \"type\":\"expense\"|\"income\"|null }.\n - \"comparison\" para comparativas con el periodo anterior.\n - Si pregunta \"cuanto gaste en transporte\": kind \"category\", category nombre exacto, period \"month\".\n - Si pregunta saldo/balance: kind \"balance\", period \"month\".\n - Si pregunta cuánto me depositaron: kind \"income\".\n\n3. action -> \"action\": { \"type\":\"delete\"|\"update\"|\"budget\"|\"currency\"|\"export\",\n \"target\":{ \"type\"?, \"category\"?, \"description\"?, \"scope\":\"last\"|\"all\" },\n \"amount\":number (solo para \"update\": el nuevo monto),\n \"budget\": { \"category\":\"...\", \"amount\":number },\n \"currency\":\"S/.\"|\"$\"|\"€\" }.\n\n4. advice -> \"advice\":true.\n\nCONTEXTO PREVIO: recibe los últimos mensajes de la conversación. Úsalos para entender referencias:\n- Si el usuario corrige algo dicho antes (\"no era 30, era 40\", \"me equivoqué\", \"era 20 no 10\"), devuelve intent \"action\" con type \"update\", target {scope:\"last\"} y amount con el nuevo monto.\n- Si continúa agregando gastos (\"y 10 en refrescos\", \"además 5 en pan\"), devuelve intent \"register\" SOLO con la nueva transacción, sin repetir las anteriores.\n- Si responde \"sí\"/\"no\" a tu confirmación, devuelve intent \"unknown\" (el botón se encarga), o repite la acción con needsConfirmation false.\n\nOTROS CAMPOS:\n- \"needsConfirmation\": true si NO estás seguro del monto/categoría/destino, o la acción es destructiva (delete/update).\n- \"message\": mensaje breve en español para mostrar al usuario.\n\nCATEGORÍAS: [${categories.join(', ')}]\nMONEDA: ${currency}\nFECHA ACTUAL: ${new Date().toISOString().split('T')[0]}\n\nPRECAUCIÓN: NUNCA inventes montos ni categorías inexistentes. Si un dato falta, deja campos vacíos/null y pon \"needsConfirmation\":true.\n`;\n}\n\n/**\n * Analiza un comando de voz/texto y devuelve el plan estructurado.\n */\nexport async function analyzeCommand(text, ctx = {}) {\n const categories = ctx.categories || DEFAULT_CATEGORIES;\n const currency = ctx.currency || 'S/.';\n const memory = Array.isArray(ctx.memory) ? ctx.memory.slice(-4) : [];\n try {\n const data = await callDeepSeek([\n { role: 'system', content: SYSTEM_PROMPT(categories, currency) },\n ...memory.map(m => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || '').slice(0, 300) })),\n { role: 'user', content: text }\n ], 0.1, { type: 'json_object' });\n const content = data.choices?.[0]?.message?.content;\n if (content) {\n return normalizeAnalysis(JSON.parse(content), categories);\n }\n } catch (err) {\n console.warn('DeepSeek analyze notice, usando motor local:', err);\n }\n return fallbackAnalyze(text, categories, currency);\n}\n\nconst INTENTS = ['register', 'query', 'action', 'advice', 'unknown'];\nconst KINDS = ['expense', 'income', 'balance', 'comparison', 'category'];\nconst PERIODS = ['today', 'week', 'month', 'prev_month', 'prev_week', 'all'];\nconst ACTION_TYPES = ['delete', 'update', 'budget', 'currency', 'export'];\n\nfunction normalizeAnalysis(raw, categories) {\n const a = { ...raw };\n a.intent = INTENTS.includes(a.intent) ? a.intent : 'unknown';\n a.needsConfirmation = !!a.needsConfirmation;\n\n if (a.intent === 'register') {\n a.transactions = Array.isArray(a.transactions)\n ? a.transactions\n .map(t => ({\n type: t.type === 'income' ? 'income' : 'expense',\n amount: typeof t.amount === 'number' && t.amount > 0 ? t.amount : null,\n category: categories.includes(t.category) ? t.category : (t.category || 'Otros'),\n description: (t.description || '').trim() || 'Gasto registrado',\n date: /^\\d{4}-\\d{2}-\\d{2}$/.test(t.date || '') ? t.date : new Date().toISOString().split('T')[0],\n recurring: !!t.recurring\n }))\n .filter(t => t.amount != null)\n : [];\n if (a.transactions.length === 0) a.needsConfirmation = true;\n } else if (a.intent === 'query') {\n const q = a.query || {};\n a.query = {\n kind: KINDS.includes(q.kind) ? q.kind : 'expense',\n category: q.category || null,\n period: PERIODS.includes(q.period) ? q.period : 'month',\n type: (q.type === 'income' || q.type === 'expense') ? q.type : null\n };\n } else if (a.intent === 'action') {\n const ac = a.action || {};\n const type = ACTION_TYPES.includes(ac.type) ? ac.type : 'unknown';\n a.action = {\n type,\n target: ac.target || {},\n amount: typeof ac.amount === 'number' && ac.amount > 0 ? ac.amount : null,\n budget: (ac.budget && ac.budget.category && typeof ac.budget.amount === 'number') ? { category: ac.budget.category, amount: ac.budget.amount } : null,\n currency: ac.currency && CURRENCIES.includes(ac.currency) ? ac.currency : null\n };\n if (type !== 'unknown') a.needsConfirmation = true;\n }\n return a;\n}\n\n// ---------------------------------------------------------------- Fallback local (A4)\nfunction categoryFromLower(lower) {\n const map = [\n [/(gallet|comida|almuerzo|cena|desayuno|pan|snack|restaurante|mercado|supermercado)/, 'Alimentación y Comida'],\n [/(taxi|bus|gasolina|pasaje|combustible|uber|transporte|moto)/, 'Transporte y Gasolina'],\n [/(luz|agua|internet|servicio|cable|tel(e)?fono|recibos|gas)/, 'Servicios (Luz, Agua, Internet)'],\n [/(cine|juego|fiesta|cerveza|regalo|netflix|spotify|entret|ocio)/, 'Entretenimiento y Ocio'],\n [/(medic|farmaci|doctor|salud|pastilla)/, 'Salud y Medicinas'],\n [/(curso|libro|pensi(o)n|universida|colegio)/, 'Educación y Cursos'],\n [/(casa|alquiler|compras|limpieza|hoga)/, 'Hogar y Compras'],\n [/(sueldo|salario|trabajo|ingreso)/, 'Ingreso (Sueldo/Trabajo)']\n ];\n for (const [re, name] of map) if (re.test(lower)) return name;\n return null;\n}\n\nconst todayISO = () => new Date().toISOString().split('T')[0];\n\nfunction simpleQuery(text) {\n const lower = text.toLowerCase();\n const q = { kind: 'expense', category: null, period: 'month', type: null };\n if (/(balance|saldo|ahorro)/.test(lower)) { q.kind = 'balance'; q.type = null; }\n else if (/(ingreso|sueldo|gan|deposito|recibi)/.test(lower)) q.type = 'income';\n\n if (/(ayer|hoy)/.test(lower)) q.period = 'today';\n else if (/(mes pasad|anterior)/.test(lower)) q.period = 'prev_month';\n else if (/(semana)/.test(lower)) q.period = 'week';\n else if (/(pasado)/.test(lower)) q.period = 'prev_month';\n\n const cat = categoryFromLower(lower);\n if (cat) { q.kind = cat === 'Ingreso (Sueldo/Trabajo)' ? 'income' : 'category'; q.category = cat; if (q.kind === 'income') q.type = 'income'; }\n return q;\n}\n\nexport function fallbackAnalyze(text, _categories, currency) {\n const lower = text.toLowerCase().trim();\n\n if (/(borra|borrar|elimina|eliminar|quita|quitar)/.test(lower)) {\n const target = { scope: /(ultimo|último|anterior|reciente|última|anterior)/.test(lower) ? 'last' : 'all' };\n if (/(gasto|gaste|compra)/.test(lower)) target.type = 'expense';\n if (/(ingreso|cobre|deposito)/.test(lower)) target.type = 'income';\n return { intent: 'action', action: { type: 'delete', target }, needsConfirmation: true, message: '' };\n }\n\n if (/(cambia|cambiar).*(soles|sol|dolares|dólares|euros|usd|eur)/.test(lower)) {\n let currency = 'S/.';\n if (/(dolares|dólares|usd|\\$)/.test(lower)) currency = '$';\n if (/(euros|euro|eur|€)/.test(lower)) currency = '€';\n return { intent: 'action', action: { type: 'currency', currency }, needsConfirmation: false, message: '' };\n }\n\n const allNumbers = lower.match(/\\d+(?:[.,]\\d{1,2})?/g);\n const lastNumber = allNumbers ? allNumbers[allNumbers.length - 1] : null;\n if (/(no era|me equivoqu[eé]|era |corrige|corregir|corrijo)/.test(lower) && lastNumber && !/(presupuesto|moneda|borra|elimina)/.test(lower)) {\n return { intent: 'action', action: { type: 'update', target: { scope: 'last' }, amount: parseFloat(lastNumber.replace(',', '.')) }, needsConfirmation: false, message: '' };\n }\n\n const budgetMatch = lower.match(/(\\d+(?:[.,]\\d{1,2})?)/);\n if (/(presupuesto|ponle|pon)\\b/.test(lower) && budgetMatch) {\n const category = categoryFromLower(lower) || 'Otros';\n return { intent: 'action', action: { type: 'budget', budget: { category, amount: parseFloat(budgetMatch[1].replace(',', '.')) } }, needsConfirmation: false, message: '' };\n }\n\n if (/(c(u|ü)anto|que|como|balance|saldo|ahorro|gaste|gast[eé]|presup)|(presupuesto)\\b/.test(lower)) {\n return { intent: 'query', query: simpleQuery(lower), needsConfirmation: false, message: '' };\n }\n\n const amountMatch = lower.match(/(\\d+(?:[.,]\\d{1,2})?)\\s*(?:soles|sol|s\\/\\.?|\\$|dolares|dólares|euros)?/i) ||\n lower.match(/(?:gasto|gaste|pague|pagué|ingreso|cobre|deposito|gane|cobr)\\s*(\\d+(?:[.,]\\d{1,2})?)/i);\n if (amountMatch) {\n const amount = parseFloat(amountMatch[1].replace(',', '.'));\n const type = /(ingreso|sueldo|gane|gana?|deposito)/.test(lower) ? 'income' : 'expense';\n\n let date = todayISO();\n if (lower.includes('ayer')) { const d = new Date(); d.setDate(d.getDate() - 1); date = d.toISOString().split('T')[0]; }\n else if (lower.includes('anteayer')) { const d = new Date(); d.setDate(d.getDate() - 2); date = d.toISOString().split('T')[0]; }\n\n const cat = type === 'income' ? 'Ingreso (Sueldo/Trabajo)' : (categoryFromLower(lower) || 'Otros');\n const recurring = /(cada mes|por mes|mensual|cada semana|semanal|recurrente)/.test(lower);\n\n let description = text.replace(/reg[íi]strame|reg[íi]st[ao]|gasto de|gaste|gasté|pagué|pag[aá]?|un gasto|ingreso de|ingres[eo]|cada mes|mensual|recurrente|del|me depositaron|depositar[on]?|cobre|cobr[aé]?|gane|gan[aé]?|recib[ií]|\\d+(?:[.,]\\d{1,2})?|soles|sol\\b|s\\/\\.?|dolares|dólares|euros/gi, '').trim();\n description = description.replace(/^(?:(?:ayer|hoy|anteayer|me|de|en|por|el|la|un|una)\\s+)+/gi, '').trim();\n if (!description || description.length < 2) description = cat.split(' ')[0];\n description = description.charAt(0).toUpperCase() + description.slice(1);\n\n return {\n intent: 'register',\n transactions: [{ type, amount: amount || 10, category: cat, description, date, recurring }],\n needsConfirmation: false,\n message: `He registrado un ${type === 'income' ? 'ingreso' : 'gasto'} de ${formatMoney(amount || 10, currency)} en \"${cat}\"${recurring ? ' (mensual)' : ''}.`\n };\n }\n\n return { intent: 'advice', needsConfirmation: false, message: '' };\n}\n\n// ---------------------------------------------------------------- Consultas locales (C1-C3)\nfunction periodRange(period, now = new Date()) {\n const iso = d => d.toISOString().split('T')[0];\n const today = iso(now);\n switch (period) {\n case 'today': return { from: today, to: today };\n case 'prev_month':\n return { from: iso(new Date(now.getFullYear(), now.getMonth() - 1, 1)), to: iso(new Date(now.getFullYear(), now.getMonth(), 0)) };\n case 'week':\n return { from: iso(new Date(now.getTime() - 7 * 86400000)), to: today };\n case 'prev_week':\n return { from: iso(new Date(now.getTime() - 14 * 86400000)), to: iso(new Date(now.getTime() - 7 * 86400000)) };\n case 'all': return { from: '0000-01-01', to: today };\n default:\n return { from: iso(new Date(now.getFullYear(), now.getMonth(), 1)), to: today };\n }\n}\n\nfunction prevRange(range) {\n const f = new Date(range.from + 'T00:00:00');\n const t = new Date(range.to + 'T00:00:00');\n const dur = (t - f) + 86400000;\n return {\n from: new Date(f.getTime() - dur).toISOString().split('T')[0],\n to: new Date(f.getTime() - 86400000).toISOString().split('T')[0]\n };\n}\n\nexport function resolveQuery(query, transactions, currency = 'S/.') {\n const range = periodRange(query.period);\n const inRange = transactions.filter(t => t.date && t.date >= range.from && t.date <= range.to);\n const income = inRange.filter(t => t.type === 'income');\n const expense = inRange.filter(t => t.type === 'expense');\n const sum = arr => arr.reduce((s, t) => s + (t.amount || 0), 0);\n const fmt = n => formatMoney(n, currency);\n\n const label = { today: 'hoy', week: 'esta semana', month: 'este mes', prev_month: 'el mes anterior', prev_week: 'la semana anterior', all: 'en total' }[query.period] || '';\n\n if (query.kind === 'balance') {\n return `Balance ${label}: ingresos ${fmt(sum(income))}, gastos ${fmt(sum(expense))}, saldo ${fmt(sum(income) - sum(expense))}.`;\n }\n if (query.kind === 'income') {\n return `Ingresos ${label}: ${fmt(sum(income))}.`;\n }\n if (query.kind === 'comparison') {\n const prev = prevRange(range);\n const before = sum(transactions.filter(t => t.type === 'expense' && t.date && t.date >= prev.from && t.date <= prev.to));\n const cur = sum(expense);\n const pct = before > 0 ? Math.round(((cur - before) / before) * 100) : null;\n const ctx = pct === null ? 'sin comparación anterior' : (pct >= 0 ? `un ${pct}% más que el periodo anterior` : `un ${Math.abs(pct)}% menos que el periodo anterior`);\n return `Gastos ${label}: ${fmt(cur)} (${ctx}).`;\n }\n const total = query.type === 'income' ? sum(income) : sum(expense);\n if (query.kind === 'category' && query.category) {\n return `${query.category} ${label}: ${fmt(sum(expense.filter(t => t.category === query.category)))}.`;\n }\n const byCat = {};\n expense.forEach(t => { byCat[t.category] = (byCat[t.category] || 0) + (t.amount || 0); });\n const top = Object.entries(byCat).sort((a, b) => b[1] - a[1])[0];\n const line = top ? ` El mayor es ${top[0]} con ${fmt(top[1])}.` : '';\n return `Gasto ${label}: ${fmt(total)}.${line}`;\n}\n\n// ---------------------------------------------------------------- Asesor financiero\nexport async function askFinancialAdvisor(question, transactions, summary) {\n try {\n const data = await callDeepSeek([\n {\n role: 'system',\n content: `Eres el asesor financiero de \"Cashflow IA\". Responde en español, conciso (máximo 3 párrafos cortos).\nRESUMEN ACTUAL:\n- Total Ingresos: S/. ${summary.totalIncome.toFixed(2)}\n- Total Gastos: S/. ${summary.totalExpense.toFixed(2)}\n- Balance: S/. ${summary.netBalance.toFixed(2)}\n- Registros: ${transactions.length}\nÚLTIMAS TRANSACCIONES:\n${JSON.stringify(transactions.slice(0, 10), null, 2)}`\n },\n { role: 'user', content: question }\n ], 0.7);\n return data.choices?.[0]?.message?.content || 'No hay respuesta del asesor.';\n } catch (err) {\n console.warn('DeepSeek notice:', err);\n return 'El asesor IA no está disponible ahora. Inténtalo más tarde.';\n }\n}"}}}}