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

View File

@@ -22,8 +22,8 @@
- [x] 4.1 Implement monthly dashboard aggregation services for totals, category breakdowns, and derived comparisons.
- [x] 4.2 Implement the dashboard API route and render dashboard sections for month-to-date metrics and comparisons.
- [ ] 4.3 Implement the offline `Ollama` insight service with structured monthly snapshot input and sparse-month fallback logic.
- [ ] 4.4 Implement insight generation and display in the dashboard, including persisted monthly insight records and offline-runtime fallback messaging.
- [x] 4.3 Implement the offline `Ollama` insight service with structured monthly snapshot input and sparse-month fallback logic.
- [x] 4.4 Implement insight generation and display in the dashboard, including persisted monthly insight records and offline-runtime fallback messaging.
## 5. Offline categorization
@@ -35,4 +35,4 @@
## 6. Verification
- [ ] 6.1 Add automated tests for validation, persistence, dashboard aggregates, offline insight fallback behavior, and category suggestion rules.
- [ ] 6.2 Verify the primary user flows in the browser, including expense entry, paycheck entry, dashboard updates, category suggestion, and insight generation.
- [x] 6.2 Verify the primary user flows in the browser, including expense entry, paycheck entry, dashboard updates, category suggestion, and insight generation.

23
package-lock.json generated
View File

@@ -10,7 +10,6 @@
"dependencies": {
"@prisma/client": "^6.6.0",
"next": "16.2.1",
"openai": "^5.10.2",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^2.15.4",
@@ -6854,27 +6853,6 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/openai": {
"version": "5.23.2",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.23.2.tgz",
"integrity": "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -8435,6 +8413,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",

View File

@@ -15,7 +15,6 @@
"dependencies": {
"@prisma/client": "^6.6.0",
"next": "16.2.1",
"openai": "^5.10.2",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^2.15.4",

View File

@@ -0,0 +1,3 @@
-- CreateIndex
CREATE UNIQUE INDEX "MonthlyInsight_month_key" ON "MonthlyInsight"("month");

View File

@@ -36,7 +36,7 @@ model Paycheck {
model MonthlyInsight {
id String @id @default(cuid())
month String
month String @unique
year Int
generatedAt DateTime @default(now())
summary String

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { getCurrentMonthKey } from "@/lib/date";
import { generateMonthlyInsight } from "@/lib/insights";
import { monthQuerySchema } from "@/lib/validation";
export async function POST(request: Request) {
const url = new URL(request.url);
const rawMonth = url.searchParams.get("month") ?? getCurrentMonthKey();
const parsed = monthQuerySchema.safeParse({ month: rawMonth });
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid insight month." },
{ status: 400 },
);
}
const result = await generateMonthlyInsight(parsed.data.month);
return NextResponse.json(result);
}

View File

@@ -9,6 +9,11 @@ import { formatCurrencyFromCents, formatPercent } from "@/lib/money";
type DashboardSnapshot = {
month: string;
insight: {
summary: string;
recommendations: string;
generatedAt: string;
} | null;
totals: {
expensesCents: number;
paychecksCents: number;
@@ -29,22 +34,27 @@ export function HomeDashboard() {
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey());
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [insightBusy, setInsightBusy] = useState(false);
useEffect(() => {
async function loadDashboard() {
const response = await fetch(`/dashboard?month=${selectedMonth}`, { cache: "no-store" });
const payload = (await response.json()) as DashboardSnapshot & { error?: string };
async function loadDashboard(month: string) {
const response = await fetch(`/dashboard?month=${month}`, { cache: "no-store" });
const payload = (await response.json()) as DashboardSnapshot & { error?: string };
if (!response.ok) {
setError(payload.error ?? "Could not load the dashboard.");
return;
}
setError(null);
setSnapshot(payload);
if (!response.ok) {
setError(payload.error ?? "Could not load the dashboard.");
return;
}
void loadDashboard();
setError(null);
setSnapshot(payload);
}
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void loadDashboard(selectedMonth);
}, 0);
return () => window.clearTimeout(timeoutId);
}, [selectedMonth]);
const topCategoryLabel = useMemo(() => {
@@ -60,6 +70,22 @@ export function HomeDashboard() {
? "No spend yet"
: formatPercent(snapshot.totals.paycheckCoverageRatio);
async function handleGenerateInsights() {
setInsightBusy(true);
const response = await fetch(`/insights/generate?month=${selectedMonth}`, { method: "POST" });
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
setInsightBusy(false);
if (!response.ok) {
setError(payload?.error ?? "Could not generate insights.");
return;
}
await loadDashboard(selectedMonth);
}
return (
<div className="space-y-10">
<section className="grid gap-6 rounded-[2rem] border border-stone-200 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.26),_transparent_32%),linear-gradient(135deg,#fffaf2,#f3efe7)] p-8 shadow-[0_28px_70px_rgba(120,90,50,0.10)] lg:grid-cols-[1.2fr_0.8fr]">
@@ -119,6 +145,43 @@ export function HomeDashboard() {
</div>
</section>
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Private monthly insight</p>
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Offline guidance for this month</h2>
</div>
<button
type="button"
onClick={() => void handleGenerateInsights()}
disabled={insightBusy}
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
>
{insightBusy ? "Generating..." : "Generate insights"}
</button>
</div>
{snapshot?.insight ? (
<div className="mt-6 grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<article className="rounded-3xl border border-stone-200 bg-[#fffcf7] px-5 py-5">
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Summary</p>
<p className="mt-3 text-base leading-7 text-stone-700">{snapshot.insight.summary}</p>
</article>
<article className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-5 py-5">
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Next month guidance</p>
<p className="mt-3 text-base leading-7 text-stone-700">{snapshot.insight.recommendations}</p>
<p className="mt-4 text-xs uppercase tracking-[0.2em] text-stone-400">
Generated {new Date(snapshot.insight.generatedAt).toLocaleString()}
</p>
</article>
</div>
) : (
<div className="mt-6 rounded-3xl border border-dashed border-stone-300 px-5 py-8 text-stone-600">
No saved insight for this month yet. Generate one to get a private offline summary.
</div>
)}
</section>
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
<div className="flex items-center justify-between gap-4">

View File

@@ -1,4 +1,5 @@
import { CATEGORY_VALUES, type CategoryValue } from "@/lib/categories";
import { generateOllamaJson } from "@/lib/ollama";
type SuggestionSource = "rule" | "model" | "unavailable";
@@ -75,25 +76,11 @@ export async function suggestCategoryForMerchant(merchantName: string): Promise<
}
try {
const response = await fetch(`${process.env.OLLAMA_URL ?? "http://127.0.0.1:11434"}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: process.env.OLLAMA_MODEL ?? "qwen2.5:7b",
format: "json",
stream: false,
prompt:
"You categorize personal expense merchants. Return JSON with one key named category. Allowed values only: RENT, FOOD, TRANSPORT, BILLS, SHOPPING, HEALTH, ENTERTAINMENT, MISC. Merchant: " +
normalized,
}),
const parsed = await generateOllamaJson<{ category?: string }>({
prompt:
"You categorize personal expense merchants. Return JSON with one key named category. Allowed values only: RENT, FOOD, TRANSPORT, BILLS, SHOPPING, HEALTH, ENTERTAINMENT, MISC. Merchant: " +
normalized,
});
if (!response.ok) {
throw new Error(`Ollama request failed with ${response.status}`);
}
const payload = (await response.json()) as { response?: string };
const parsed = payload.response ? JSON.parse(payload.response) : null;
const category = parseSuggestedCategory(parsed?.category);
if (!category) {

View File

@@ -32,6 +32,7 @@ describe("buildDashboardSnapshot", () => {
createdAt: new Date("2026-03-01T10:00:00Z"),
},
],
insight: null,
});
expect(snapshot.totals.expensesCents).toBe(123200);
@@ -48,6 +49,7 @@ describe("buildDashboardSnapshot", () => {
month: "2026-03",
expenses: [],
paychecks: [],
insight: null,
});
expect(snapshot.totals.expensesCents).toBe(0);
@@ -55,5 +57,6 @@ describe("buildDashboardSnapshot", () => {
expect(snapshot.totals.paycheckCoverageRatio).toBeNull();
expect(snapshot.comparisons.highestCategory).toBeNull();
expect(snapshot.comparisons.largestExpense).toBeNull();
expect(snapshot.insight).toBeNull();
});
});

View File

@@ -1,4 +1,4 @@
import type { Expense, Paycheck } from "@prisma/client";
import type { Expense, MonthlyInsight, Paycheck } from "@prisma/client";
import { db } from "@/lib/db";
import {
@@ -12,6 +12,11 @@ import {
export type DashboardSnapshot = {
month: string;
insight: {
summary: string;
recommendations: string;
generatedAt: string;
} | null;
totals: {
expensesCents: number;
paychecksCents: number;
@@ -32,6 +37,7 @@ export function buildDashboardSnapshot(input: {
month: string;
expenses: Expense[];
paychecks: Paycheck[];
insight?: MonthlyInsight | null;
}): DashboardSnapshot {
const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month));
const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, input.month));
@@ -80,6 +86,13 @@ export function buildDashboardSnapshot(input: {
return {
month: input.month,
insight: input.insight
? {
summary: input.insight.summary,
recommendations: input.insight.recommendations,
generatedAt: input.insight.generatedAt.toISOString(),
}
: null,
totals: {
expensesCents,
paychecksCents,
@@ -115,10 +128,11 @@ export function buildDashboardSnapshot(input: {
}
export async function getDashboardSnapshot(month = getCurrentMonthKey()) {
const [expenses, paychecks] = await Promise.all([
const [expenses, paychecks, insight] = await Promise.all([
db.expense.findMany({ orderBy: [{ date: "desc" }, { createdAt: "desc" }] }),
db.paycheck.findMany({ orderBy: [{ payDate: "desc" }, { createdAt: "desc" }] }),
db.monthlyInsight.findUnique({ where: { month } }),
]);
return buildDashboardSnapshot({ month, expenses, paychecks });
return buildDashboardSnapshot({ month, expenses, paychecks, insight });
}

91
src/lib/insights.test.ts Normal file
View File

@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => {
const monthlyInsight = {
upsert: vi.fn(async ({ where, update, create }: { where: { month: string }; update: Record<string, unknown>; create: Record<string, unknown> }) => ({
id: "insight-1",
month: where.month,
year: (update.year ?? create.year) as number,
summary: (update.summary ?? create.summary) as string,
recommendations: (update.recommendations ?? create.recommendations) as string,
inputSnapshot: (update.inputSnapshot ?? create.inputSnapshot) as string,
generatedAt: new Date("2026-03-23T12:00:00.000Z"),
})),
findUnique: vi.fn(),
};
return {
db: {
expense: { findMany: vi.fn() },
paycheck: { findMany: vi.fn() },
monthlyInsight,
},
};
});
describe("generateMonthlyInsight", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("stores a fallback for sparse months", async () => {
const { db } = await import("@/lib/db");
const { generateMonthlyInsight } = await import("@/lib/insights");
vi.mocked(db.expense.findMany).mockResolvedValue([]);
vi.mocked(db.paycheck.findMany).mockResolvedValue([]);
const result = await generateMonthlyInsight("2026-03");
expect(result.source).toBe("fallback");
expect(result.insight.summary).toContain("not enough activity");
});
it("stores model output when Ollama responds", async () => {
const { db } = await import("@/lib/db");
const { generateMonthlyInsight } = await import("@/lib/insights");
vi.mocked(db.expense.findMany).mockResolvedValue([
{
id: "expense-1",
title: "Groceries",
date: "2026-03-23",
amountCents: 3200,
category: "FOOD",
createdAt: new Date("2026-03-23T10:00:00.000Z"),
},
{
id: "expense-2",
title: "Rent",
date: "2026-03-02",
amountCents: 120000,
category: "RENT",
createdAt: new Date("2026-03-02T10:00:00.000Z"),
},
]);
vi.mocked(db.paycheck.findMany).mockResolvedValue([
{
id: "paycheck-1",
payDate: "2026-03-01",
amountCents: 180000,
createdAt: new Date("2026-03-01T10:00:00.000Z"),
},
]);
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
response: JSON.stringify({
summary: "Spending is stable.",
recommendations: "Keep food spending under watch.",
}),
}),
} as Response);
const result = await generateMonthlyInsight("2026-03");
expect(result.source).toBe("model");
expect(result.insight.summary).toBe("Spending is stable.");
expect(result.insight.recommendations).toBe("Keep food spending under watch.");
});
});

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" };
}
}

46
src/lib/ollama.ts Normal file
View File

@@ -0,0 +1,46 @@
export class OllamaUnavailableError extends Error {
constructor(message = "Local AI runtime is unavailable.") {
super(message);
this.name = "OllamaUnavailableError";
}
}
type GenerateJsonInput = {
prompt: string;
model?: string;
};
export async function generateOllamaJson<T>({ prompt, model }: GenerateJsonInput): Promise<T> {
const baseUrl = (process.env.OLLAMA_URL ?? "http://127.0.0.1:11434").replace(/\/$/, "");
const selectedModel = model ?? process.env.OLLAMA_MODEL ?? "qwen3.5:9b";
let response: Response;
try {
response = await fetch(`${baseUrl}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: selectedModel,
format: "json",
stream: false,
prompt,
}),
});
} catch {
throw new OllamaUnavailableError("Ollama is not reachable at the configured URL.");
}
if (!response.ok) {
throw new OllamaUnavailableError(`Ollama request failed with status ${response.status}.`);
}
const payload = (await response.json()) as { response?: string; thinking?: string };
const jsonText = payload.response?.trim() ? payload.response : payload.thinking;
if (!jsonText) {
throw new OllamaUnavailableError("Ollama returned an empty response.");
}
return JSON.parse(jsonText) as T;
}