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.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
23
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MonthlyInsight_month_key" ON "MonthlyInsight"("month");
|
||||
|
||||
@@ -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
|
||||
|
||||
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 = {
|
||||
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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
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