import type { MonthlyInsight } from "@prisma/client"; import { db } from "@/lib/db"; import { getCurrentMonthKey } from "@/lib/date"; import { getDashboardSnapshot } from "@/lib/dashboard"; import { generateOllamaJson, OllamaUnavailableError } from "@/lib/ollama"; export type InsightResult = { insight: MonthlyInsight; source: "model" | "fallback"; }; type GeneratedInsightPayload = { summary?: string; recommendations?: string; }; function coerceInsightText(value: unknown) { if (typeof value === "string") { return value.trim(); } if (Array.isArray(value)) { return value .map((item) => (typeof item === "string" ? item.trim() : "")) .filter(Boolean) .join(" ") .trim(); } if (value && typeof value === "object") { return Object.values(value) .map((item) => (typeof item === "string" ? item.trim() : "")) .filter(Boolean) .join(" ") .trim(); } return ""; } function buildFallbackInsight(month: string, reason: "sparse" | "unavailable") { if (reason === "unavailable") { return { summary: `Local insights are unavailable for ${month} because Ollama or the selected model is not reachable right now.`, recommendations: "Keep tracking manually for now. Once Ollama is running again, regenerate this month to get a private offline summary.", }; } return { summary: `There is not enough activity in ${month} yet to generate a useful insight summary.`, recommendations: "Add more expenses or paychecks this month, then generate insights again for stronger spending and timing guidance.", }; } function buildInsightPrompt(snapshot: Awaited>) { return [ "You are a private offline financial summarizer for a single-user expense tracker.", "Return strict JSON with keys summary and recommendations.", "Keep the tone practical, concise, and non-judgmental.", "Focus on spending patterns, category spikes, paycheck timing, and next-month guidance.", `Month: ${snapshot.month}`, `Total expenses cents: ${snapshot.totals.expensesCents}`, `Total paychecks cents: ${snapshot.totals.paychecksCents}`, `Net cash flow cents: ${snapshot.totals.netCashFlowCents}`, `Average daily spend cents: ${snapshot.totals.averageDailySpendCents}`, `Highest category: ${snapshot.comparisons.highestCategory ? `${snapshot.comparisons.highestCategory.category} ${snapshot.comparisons.highestCategory.amountCents}` : "none"}`, `Largest expense: ${snapshot.comparisons.largestExpense ? `${snapshot.comparisons.largestExpense.title} ${snapshot.comparisons.largestExpense.amountCents}` : "none"}`, `Category breakdown: ${JSON.stringify(snapshot.categoryBreakdown)}`, `Recent expenses: ${JSON.stringify(snapshot.recentExpenses)}`, `Daily chart points: ${JSON.stringify(snapshot.chart)}`, ].join("\n"); } async function upsertMonthlyInsight(month: string, payload: { summary: string; recommendations: string; inputSnapshot: string }) { const year = Number.parseInt(month.slice(0, 4), 10); return db.monthlyInsight.upsert({ where: { month }, update: { year, summary: payload.summary, recommendations: payload.recommendations, inputSnapshot: payload.inputSnapshot, generatedAt: new Date(), }, create: { month, year, summary: payload.summary, recommendations: payload.recommendations, inputSnapshot: payload.inputSnapshot, }, }); } export async function getStoredMonthlyInsight(month = getCurrentMonthKey()) { return db.monthlyInsight.findUnique({ where: { month } }); } export async function generateMonthlyInsight(month = getCurrentMonthKey()): Promise { const snapshot = await getDashboardSnapshot(month); const totalEvents = snapshot.recentExpenses.length + snapshot.chart.filter((point) => point.paychecksCents > 0).length; if (totalEvents < 2) { const fallback = buildFallbackInsight(month, "sparse"); const insight = await upsertMonthlyInsight(month, { ...fallback, inputSnapshot: JSON.stringify(snapshot), }); return { insight, source: "fallback" }; } try { const generated = await generateOllamaJson({ prompt: buildInsightPrompt(snapshot), }); const summary = coerceInsightText(generated.summary); const recommendations = coerceInsightText(generated.recommendations); if (!summary || !recommendations) { throw new OllamaUnavailableError("The local model returned an incomplete insight response."); } const insight = await upsertMonthlyInsight(month, { summary, recommendations, inputSnapshot: JSON.stringify(snapshot), }); return { insight, source: "model" }; } catch (error) { if (!(error instanceof OllamaUnavailableError)) { throw error; } const fallback = buildFallbackInsight(month, "unavailable"); const insight = await upsertMonthlyInsight(month, { ...fallback, inputSnapshot: JSON.stringify(snapshot), }); return { insight, source: "fallback" }; } }