Add offline monthly insights with Ollama
This commit is contained in:
@@ -22,8 +22,8 @@
|
|||||||
|
|
||||||
- [x] 4.1 Implement monthly dashboard aggregation services for totals, category breakdowns, and derived comparisons.
|
- [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.
|
- [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.
|
- [x] 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.4 Implement insight generation and display in the dashboard, including persisted monthly insight records and offline-runtime fallback messaging.
|
||||||
|
|
||||||
## 5. Offline categorization
|
## 5. Offline categorization
|
||||||
|
|
||||||
@@ -35,4 +35,4 @@
|
|||||||
## 6. Verification
|
## 6. Verification
|
||||||
|
|
||||||
- [ ] 6.1 Add automated tests for validation, persistence, dashboard aggregates, offline insight fallback behavior, and category suggestion rules.
|
- [ ] 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
23
package-lock.json
generated
@@ -10,7 +10,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.6.0",
|
"@prisma/client": "^6.6.0",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"openai": "^5.10.2",
|
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
@@ -6854,27 +6853,6 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -8435,6 +8413,7 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.6.0",
|
"@prisma/client": "^6.6.0",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"openai": "^5.10.2",
|
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "MonthlyInsight_month_key" ON "MonthlyInsight"("month");
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ model Paycheck {
|
|||||||
|
|
||||||
model MonthlyInsight {
|
model MonthlyInsight {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
month String
|
month String @unique
|
||||||
year Int
|
year Int
|
||||||
generatedAt DateTime @default(now())
|
generatedAt DateTime @default(now())
|
||||||
summary String
|
summary String
|
||||||
|
|||||||
21
src/app/insights/generate/route.ts
Normal file
21
src/app/insights/generate/route.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@ import { formatCurrencyFromCents, formatPercent } from "@/lib/money";
|
|||||||
|
|
||||||
type DashboardSnapshot = {
|
type DashboardSnapshot = {
|
||||||
month: string;
|
month: string;
|
||||||
|
insight: {
|
||||||
|
summary: string;
|
||||||
|
recommendations: string;
|
||||||
|
generatedAt: string;
|
||||||
|
} | null;
|
||||||
totals: {
|
totals: {
|
||||||
expensesCents: number;
|
expensesCents: number;
|
||||||
paychecksCents: number;
|
paychecksCents: number;
|
||||||
@@ -29,22 +34,27 @@ export function HomeDashboard() {
|
|||||||
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey());
|
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey());
|
||||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [insightBusy, setInsightBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
async function loadDashboard(month: string) {
|
||||||
async function loadDashboard() {
|
const response = await fetch(`/dashboard?month=${month}`, { cache: "no-store" });
|
||||||
const response = await fetch(`/dashboard?month=${selectedMonth}`, { cache: "no-store" });
|
const payload = (await response.json()) as DashboardSnapshot & { error?: string };
|
||||||
const payload = (await response.json()) as DashboardSnapshot & { error?: string };
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setError(payload.error ?? "Could not load the dashboard.");
|
setError(payload.error ?? "Could not load the dashboard.");
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
setSnapshot(payload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadDashboard();
|
setError(null);
|
||||||
|
setSnapshot(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
void loadDashboard(selectedMonth);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, [selectedMonth]);
|
}, [selectedMonth]);
|
||||||
|
|
||||||
const topCategoryLabel = useMemo(() => {
|
const topCategoryLabel = useMemo(() => {
|
||||||
@@ -60,6 +70,22 @@ export function HomeDashboard() {
|
|||||||
? "No spend yet"
|
? "No spend yet"
|
||||||
: formatPercent(snapshot.totals.paycheckCoverageRatio);
|
: 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 (
|
return (
|
||||||
<div className="space-y-10">
|
<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]">
|
<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>
|
</div>
|
||||||
</section>
|
</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]">
|
<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="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">
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CATEGORY_VALUES, type CategoryValue } from "@/lib/categories";
|
import { CATEGORY_VALUES, type CategoryValue } from "@/lib/categories";
|
||||||
|
import { generateOllamaJson } from "@/lib/ollama";
|
||||||
|
|
||||||
type SuggestionSource = "rule" | "model" | "unavailable";
|
type SuggestionSource = "rule" | "model" | "unavailable";
|
||||||
|
|
||||||
@@ -75,25 +76,11 @@ export async function suggestCategoryForMerchant(merchantName: string): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${process.env.OLLAMA_URL ?? "http://127.0.0.1:11434"}/api/generate`, {
|
const parsed = await generateOllamaJson<{ category?: string }>({
|
||||||
method: "POST",
|
prompt:
|
||||||
headers: { "Content-Type": "application/json" },
|
"You categorize personal expense merchants. Return JSON with one key named category. Allowed values only: RENT, FOOD, TRANSPORT, BILLS, SHOPPING, HEALTH, ENTERTAINMENT, MISC. Merchant: " +
|
||||||
body: JSON.stringify({
|
normalized,
|
||||||
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,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
const category = parseSuggestedCategory(parsed?.category);
|
||||||
|
|
||||||
if (!category) {
|
if (!category) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ describe("buildDashboardSnapshot", () => {
|
|||||||
createdAt: new Date("2026-03-01T10:00:00Z"),
|
createdAt: new Date("2026-03-01T10:00:00Z"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
insight: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(snapshot.totals.expensesCents).toBe(123200);
|
expect(snapshot.totals.expensesCents).toBe(123200);
|
||||||
@@ -48,6 +49,7 @@ describe("buildDashboardSnapshot", () => {
|
|||||||
month: "2026-03",
|
month: "2026-03",
|
||||||
expenses: [],
|
expenses: [],
|
||||||
paychecks: [],
|
paychecks: [],
|
||||||
|
insight: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(snapshot.totals.expensesCents).toBe(0);
|
expect(snapshot.totals.expensesCents).toBe(0);
|
||||||
@@ -55,5 +57,6 @@ describe("buildDashboardSnapshot", () => {
|
|||||||
expect(snapshot.totals.paycheckCoverageRatio).toBeNull();
|
expect(snapshot.totals.paycheckCoverageRatio).toBeNull();
|
||||||
expect(snapshot.comparisons.highestCategory).toBeNull();
|
expect(snapshot.comparisons.highestCategory).toBeNull();
|
||||||
expect(snapshot.comparisons.largestExpense).toBeNull();
|
expect(snapshot.comparisons.largestExpense).toBeNull();
|
||||||
|
expect(snapshot.insight).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { db } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,11 @@ import {
|
|||||||
|
|
||||||
export type DashboardSnapshot = {
|
export type DashboardSnapshot = {
|
||||||
month: string;
|
month: string;
|
||||||
|
insight: {
|
||||||
|
summary: string;
|
||||||
|
recommendations: string;
|
||||||
|
generatedAt: string;
|
||||||
|
} | null;
|
||||||
totals: {
|
totals: {
|
||||||
expensesCents: number;
|
expensesCents: number;
|
||||||
paychecksCents: number;
|
paychecksCents: number;
|
||||||
@@ -32,6 +37,7 @@ export function buildDashboardSnapshot(input: {
|
|||||||
month: string;
|
month: string;
|
||||||
expenses: Expense[];
|
expenses: Expense[];
|
||||||
paychecks: Paycheck[];
|
paychecks: Paycheck[];
|
||||||
|
insight?: MonthlyInsight | null;
|
||||||
}): DashboardSnapshot {
|
}): DashboardSnapshot {
|
||||||
const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month));
|
const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month));
|
||||||
const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, input.month));
|
const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, input.month));
|
||||||
@@ -80,6 +86,13 @@ export function buildDashboardSnapshot(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
month: input.month,
|
month: input.month,
|
||||||
|
insight: input.insight
|
||||||
|
? {
|
||||||
|
summary: input.insight.summary,
|
||||||
|
recommendations: input.insight.recommendations,
|
||||||
|
generatedAt: input.insight.generatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
totals: {
|
totals: {
|
||||||
expensesCents,
|
expensesCents,
|
||||||
paychecksCents,
|
paychecksCents,
|
||||||
@@ -115,10 +128,11 @@ export function buildDashboardSnapshot(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardSnapshot(month = getCurrentMonthKey()) {
|
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.expense.findMany({ orderBy: [{ date: "desc" }, { createdAt: "desc" }] }),
|
||||||
db.paycheck.findMany({ orderBy: [{ payDate: "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
91
src/lib/insights.test.ts
Normal 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
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/lib/ollama.ts
Normal file
46
src/lib/ollama.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user