Add offline monthly insights with Ollama
This commit is contained in:
149
src/lib/insights.ts
Normal file
149
src/lib/insights.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user