Add offline monthly insights with Ollama

This commit is contained in:
2026-03-23 14:12:35 -04:00
parent 696d393fca
commit a745c0ca1e
13 changed files with 415 additions and 60 deletions

149
src/lib/insights.ts Normal file
View File

@@ -0,0 +1,149 @@
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<ReturnType<typeof getDashboardSnapshot>>) {
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<InsightResult> {
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<GeneratedInsightPayload>({
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" };
}
}