150 lines
5.0 KiB
TypeScript
150 lines
5.0 KiB
TypeScript
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" };
|
|
}
|
|
}
|