Add savings chat analysis feature

This commit is contained in:
Matthias
2026-06-15 18:26:25 +02:00
parent d65e7681ac
commit 4869402d45
26 changed files with 2789 additions and 163 deletions

View File

@@ -34,6 +34,7 @@ import type * as lib_helpers from "../lib/helpers.js";
import type * as lib_month from "../lib/month.js";
import type * as lib_seedCategories from "../lib/seedCategories.js";
import type * as loans from "../loans.js";
import type * as savingsChat from "../savingsChat.js";
import type * as settings from "../settings.js";
import type * as transactions from "../transactions.js";
import type * as users from "../users.js";
@@ -71,6 +72,7 @@ declare const fullApi: ApiFromModules<{
"lib/month": typeof lib_month;
"lib/seedCategories": typeof lib_seedCategories;
loans: typeof loans;
savingsChat: typeof savingsChat;
settings: typeof settings;
transactions: typeof transactions;
users: typeof users;

View File

@@ -1,11 +1,11 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import type { MutationCtx, QueryCtx } from "../_generated/server";
import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server";
import type { Id } from "../_generated/dataModel";
import { categorize, roundEur } from "./categorize";
import { computeEffectiveMonth, resolveAssignedAndEffective } from "./month";
import { computeDedupHash } from "./comdirectMap";
export async function requireUserId(ctx: QueryCtx | MutationCtx): Promise<Id<"users">> {
export async function requireUserId(ctx: QueryCtx | MutationCtx | ActionCtx): Promise<Id<"users">> {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Nicht angemeldet");
return userId;

194
convex/savingsChat.test.ts Normal file
View File

@@ -0,0 +1,194 @@
/// <reference types="vite/client" />
import { convexTest } from "convex-test";
import { describe, expect, test } from "vitest";
import { api, internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
delete modules["./savingsChat.test.ts"];
describe("savingsChat.getContext", () => {
test("counts and sums every matching transaction before applying prompt limits", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Test User",
email: "test@example.com",
});
const giroAccountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const otherAccountId = await ctx.db.insert("accounts", {
userId,
name: "Tagesgeld",
type: "savings",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const amounts: number[] = [];
const months = ["2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06"];
for (let index = 0; index < 450; index++) {
const month = months[index % months.length];
const day = String((index % 27) + 1).padStart(2, "0");
const bookingDate = `${month}-${day}`;
const amount = index % 3 === 0 ? 100 : -25;
amounts.push(amount);
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
bookingDate,
valueDate: bookingDate,
description: `Giro transaction ${index}`,
counterparty: "Counterparty",
amount,
isPending: false,
effectiveMonth: index % 10 === 0 ? undefined : bookingDate.slice(0, 7),
});
}
for (let index = 0; index < 50; index++) {
const bookingDate = `2026-06-${String((index % 27) + 1).padStart(2, "0")}`;
await ctx.db.insert("transactions", {
userId,
accountId: otherAccountId,
bookingDate,
valueDate: bookingDate,
description: `Other account transaction ${index}`,
amount: 999,
isPending: false,
effectiveMonth: bookingDate.slice(0, 7),
});
}
return {
userId,
giroAccountId,
expectedIncome: amounts.filter((amount) => amount > 0).reduce((sum, amount) => sum + amount, 0),
expectedExpenses: amounts.filter((amount) => amount < 0).reduce((sum, amount) => sum + amount, 0),
expectedBalance: amounts.reduce((sum, amount) => sum + amount, 0),
};
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const context = await asUser.query(api.savingsChat.getContext, {
from: "2025-12-01",
to: "2026-06-30",
accountId: seeded.giroAccountId as Id<"accounts">,
basis: "effective",
});
expect(context.accountName).toBe("Girokonto");
expect(context.isComplete).toBe(true);
expect(context.totals.transactionCount).toBe(450);
expect(context.totals.income).toBe(seeded.expectedIncome);
expect(context.totals.expenses).toBe(seeded.expectedExpenses);
expect(context.totals.balance).toBe(seeded.expectedBalance);
});
test("builds complete prompt lines for every matching transaction", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Prompt User",
email: "prompt@example.com",
});
const categoryId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel",
kind: "ausgabe",
block: "variabel",
color: "#22c55e",
sortOrder: 1,
isSystem: false,
});
const giroAccountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const otherAccountId = await ctx.db.insert("accounts", {
userId,
name: "Depot",
type: "investment",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
categoryId,
bookingDate: "2026-02-14",
valueDate: "2026-02-14",
description: "Supermarkt",
counterparty: "Markt GmbH",
amount: -42.5,
isPending: false,
});
await ctx.db.insert("transactions", {
userId,
accountId: giroAccountId,
bookingDate: "2026-02-15",
valueDate: "2026-02-15",
description: "Gehalt",
counterparty: "Arbeitgeber",
amount: 2500,
isPending: false,
effectiveMonth: "2026-02",
});
await ctx.db.insert("transactions", {
userId,
accountId: otherAccountId,
bookingDate: "2026-02-16",
valueDate: "2026-02-16",
description: "Other account should not appear",
amount: 999,
isPending: false,
effectiveMonth: "2026-02",
});
return { userId, giroAccountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
const context = await asUser.query(internal.savingsChat.getPromptContext, {
from: "2026-02-01",
to: "2026-02-28",
accountId: seeded.giroAccountId as Id<"accounts">,
basis: "effective",
});
expect(context.totals.transactionCount).toBe(2);
expect(context.transactionLines).toHaveLength(2);
expect(context.transactionLines.join("\n")).toContain(
"2026-02-14 | Supermarkt (Markt GmbH) | -42.50€ | Lebensmittel | Girokonto",
);
expect(context.transactionLines.join("\n")).toContain(
"2026-02-15 | Gehalt (Arbeitgeber) | 2500.00€ | Ohne Kategorie | Girokonto",
);
expect(context.transactionLines.join("\n")).not.toContain("Other account should not appear");
});
});

399
convex/savingsChat.ts Normal file
View File

@@ -0,0 +1,399 @@
import { action, internalQuery, query } from "./_generated/server";
import { v } from "convex/values";
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import { internal } from "./_generated/api";
import { bookingMonth } from "./lib/month";
import { requireUserId } from "./lib/helpers";
import type { Doc, Id } from "./_generated/dataModel";
import type { QueryCtx } from "./_generated/server";
type ChatRole = "user" | "assistant";
type ChatMessage = { role: ChatRole; content: string };
const chatMessageValidator = v.object({
role: v.union(v.literal("user"), v.literal("assistant")),
content: v.string(),
});
const MAX_CONVERSATION_MESSAGES = 20;
const MAX_PROMPT_CHARACTERS = 180_000;
type ChatContextArgs = {
from: string;
to: string;
accountId?: Id<"accounts">;
basis: "effective" | "booking";
};
type ChatContextSummary = {
from: string;
to: string;
basis: "effective" | "booking";
accountId?: Id<"accounts">;
accountName?: string;
totals: { transactionCount: number; income: number; expenses: number; balance: number };
isComplete: true;
};
type ChatPromptContext = ChatContextSummary & {
transactionLines: string[];
};
type ChatAskResult = {
model: string;
answer: string;
usedTransactions: number;
usedBalance: { income: number; expenses: number; balance: number };
};
function formatEuro(value: number): string {
return `${value.toFixed(2)}`;
}
function buildSystemPrompt(context: { from: string; to: string; basis: string; accountName?: string }) {
return [
"Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.",
"Nutze ausschließlich die gelieferten Umsätze als Kontext und beziehe dich nur auf die angegebenen Werte.",
"Antworte auf Deutsch, kurz und handlungsorientiert.",
`Zeitraum: ${context.from} bis ${context.to}.`,
`Basis: ${context.basis}.`,
context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.",
"Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.",
"Verwende keine Links, keine HTML-Tags und keine Emojis.",
].join(" ");
}
function buildPrompt(context: ChatPromptContext, conversation: ChatMessage[]) {
return [
"Kontext der Auswertung:",
`Zeitraum: ${context.from} bis ${context.to}`,
`Basis: ${context.basis}`,
`Konto: ${context.accountName ?? "Alle Konten"}`,
`Anzahl Umsätze: ${context.totals.transactionCount}`,
`Einnahmen: ${formatEuro(context.totals.income)}`,
`Ausgaben: ${formatEuro(context.totals.expenses)}`,
`Saldo: ${formatEuro(context.totals.balance)}`,
"",
"Umsätze (neueste zuerst):",
...(context.transactionLines.length > 0
? context.transactionLines
: ["Keine Umsätze im Zeitraum."]),
"",
"Gesprächsverlauf:",
...conversation.map((message) => `${message.role}: ${message.content}`),
].join("\n");
}
function normalizeRole(role: ChatRole): "user" | "assistant" {
return role;
}
function sortTransactionsForContext(
transactions: Doc<"transactions">[],
basis: ChatContextArgs["basis"],
) {
return transactions.sort((a, b) => {
const aMonth = basis === "effective" ? a.effectiveMonth ?? bookingMonth(a.bookingDate) ?? "" : "";
const bMonth = basis === "effective" ? b.effectiveMonth ?? bookingMonth(b.bookingDate) ?? "" : "";
const aDate = basis === "booking" ? a.bookingDate ?? "" : a.valueDate ?? a.bookingDate ?? "";
const bDate = basis === "booking" ? b.bookingDate ?? "" : b.valueDate ?? b.bookingDate ?? "";
const aKey = `${aMonth}|${aDate}|${a._creationTime}`;
const bKey = `${bMonth}|${bDate}|${b._creationTime}`;
return bKey.localeCompare(aKey);
});
}
async function loadMatchingTransactions(
ctx: QueryCtx,
userId: Id<"users">,
args: ChatContextArgs,
): Promise<Doc<"transactions">[]> {
const monthFrom = args.from.slice(0, 7);
const monthTo = args.to.slice(0, 7);
const transactions: Doc<"transactions">[] = [];
if (args.basis === "effective") {
if (args.accountId) {
const accountId = args.accountId;
const q = ctx.db
.query("transactions")
.withIndex("by_user_account_effmonth", (index) =>
index
.eq("userId", userId)
.eq("accountId", accountId)
.gte("effectiveMonth", monthFrom)
.lte("effectiveMonth", monthTo),
)
.order("desc");
for await (const tx of q) transactions.push(tx);
const fallback = ctx.db
.query("transactions")
.withIndex("by_user_account_booking", (index) =>
index
.eq("userId", userId)
.eq("accountId", accountId)
.gte("bookingDate", args.from)
.lte("bookingDate", args.to),
)
.order("desc");
for await (const tx of fallback) {
if (tx.effectiveMonth === undefined) transactions.push(tx);
}
return sortTransactionsForContext(transactions, args.basis);
}
const q = ctx.db
.query("transactions")
.withIndex("by_user_effmonth", (index) =>
index.eq("userId", userId).gte("effectiveMonth", monthFrom).lte("effectiveMonth", monthTo),
)
.order("desc");
for await (const tx of q) transactions.push(tx);
const fallback = ctx.db
.query("transactions")
.withIndex("by_user_booking", (index) =>
index.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to),
)
.order("desc");
for await (const tx of fallback) {
if (tx.effectiveMonth === undefined) transactions.push(tx);
}
return sortTransactionsForContext(transactions, args.basis);
}
if (args.accountId) {
const accountId = args.accountId;
const q = ctx.db
.query("transactions")
.withIndex("by_user_account_booking", (index) =>
index
.eq("userId", userId)
.eq("accountId", accountId)
.gte("bookingDate", args.from)
.lte("bookingDate", args.to),
)
.order("desc");
for await (const tx of q) transactions.push(tx);
return transactions;
}
const q = ctx.db
.query("transactions")
.withIndex("by_user_booking", (index) =>
index.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to),
)
.order("desc");
for await (const tx of q) transactions.push(tx);
return sortTransactionsForContext(transactions, args.basis);
}
function calculateTotals(transactions: Doc<"transactions">[]) {
const totals = transactions.reduce(
(acc, tx) => {
if (tx.amount > 0) acc.income += tx.amount;
if (tx.amount < 0) acc.expenses += tx.amount;
acc.balance += tx.amount;
acc.transactionCount += 1;
return acc;
},
{ income: 0, expenses: 0, balance: 0, transactionCount: 0 },
);
return {
transactionCount: totals.transactionCount,
income: Math.round(totals.income * 100) / 100,
expenses: Math.round(totals.expenses * 100) / 100,
balance: Math.round(totals.balance * 100) / 100,
};
}
async function buildContextSummary(
ctx: QueryCtx,
userId: Id<"users">,
args: ChatContextArgs,
): Promise<{ summary: ChatContextSummary; transactions: Doc<"transactions">[] }> {
const transactions = await loadMatchingTransactions(ctx, userId, args);
const account = args.accountId ? await ctx.db.get(args.accountId) : null;
return {
summary: {
from: args.from,
to: args.to,
basis: args.basis,
accountId: args.accountId,
accountName: account?.userId === userId ? account.name : undefined,
totals: calculateTotals(transactions),
isComplete: true,
},
transactions,
};
}
const contextArgsValidator = {
from: v.string(),
to: v.string(),
accountId: v.optional(v.id("accounts")),
basis: v.union(v.literal("effective"), v.literal("booking")),
};
const totalsValidator = v.object({
transactionCount: v.number(),
income: v.number(),
expenses: v.number(),
balance: v.number(),
});
const contextSummaryValidator = v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
totals: totalsValidator,
isComplete: v.literal(true),
});
export const getContext = query({
args: contextArgsValidator,
returns: contextSummaryValidator,
handler: async (ctx, args): Promise<ChatContextSummary> => {
const userId = await requireUserId(ctx);
const { summary } = await buildContextSummary(ctx, userId, args);
return summary;
},
});
function toDisplayContextLine(
tx: Doc<"transactions">,
categoryById: Map<Id<"categories">, string>,
accountById: Map<Id<"accounts">, string>,
) {
const date = tx.valueDate || tx.bookingDate || "n/a";
const amount = formatEuro(tx.amount);
const name = tx.counterparty ?? "";
const category = tx.categoryId ? categoryById.get(tx.categoryId) : "Ohne Kategorie";
const account = tx.accountId ? accountById.get(tx.accountId) : "Ohne Konto";
return `${date} | ${tx.description} (${name}) | ${amount} | ${category ?? "Ohne Kategorie"} | ${account ?? "Ohne Konto"}${
tx.isPending ? " | offen" : ""
}`;
}
export const getPromptContext = internalQuery({
args: contextArgsValidator,
returns: v.object({
...contextSummaryValidator.fields,
transactionLines: v.array(v.string()),
}),
handler: async (ctx, args): Promise<ChatPromptContext> => {
const userId = await requireUserId(ctx);
const { summary, transactions } = await buildContextSummary(ctx, userId, args);
const categories = await ctx.db
.query("categories")
.withIndex("by_user", (index) => index.eq("userId", userId))
.collect();
const accounts = await ctx.db
.query("accounts")
.withIndex("by_user", (index) => index.eq("userId", userId))
.collect();
const categoryById = new Map(categories.map((category) => [category._id, category.name]));
const accountById = new Map(accounts.map((account) => [account._id, account.name]));
return {
...summary,
transactionLines: transactions.map((tx) =>
toDisplayContextLine(tx, categoryById, accountById),
),
};
},
});
export const ask = action({
args: {
messages: v.array(chatMessageValidator),
from: v.string(),
to: v.string(),
accountId: v.optional(v.id("accounts")),
basis: v.union(v.literal("effective"), v.literal("booking")),
},
returns: v.object({
model: v.string(),
answer: v.string(),
usedTransactions: v.number(),
usedBalance: v.object({
income: v.number(),
expenses: v.number(),
balance: v.number(),
}),
}),
handler: async (ctx, args): Promise<ChatAskResult> => {
if (args.messages.length === 0) {
throw new Error("Kein Nutzernachrichttext vorhanden.");
}
if (!process.env.OPENAI_API_KEY) {
throw new Error(
"OPENAI_API_KEY ist nicht gesetzt. Bitte API-Key in den Convex-Umgebungsvariablen hinterlegen.",
);
}
await requireUserId(ctx);
const context: ChatPromptContext = await ctx.runQuery(internal.savingsChat.getPromptContext, {
from: args.from,
to: args.to,
accountId: args.accountId,
basis: args.basis,
});
const lastMessages = args.messages
.map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content }))
.slice(-MAX_CONVERSATION_MESSAGES);
const prompt = buildPrompt(context, lastMessages);
if (prompt.length > MAX_PROMPT_CHARACTERS) {
throw new Error(
"Der ausgewählte Zeitraum enthält zu viele Umsatzdetails für eine vollständige KI-Anfrage. Bitte Zeitraum oder Konto eingrenzen.",
);
}
const system = buildSystemPrompt(context);
const envModel = process.env.SAVINGS_CHAT_MODEL?.trim();
const candidates = [
envModel,
"gpt-5.4-mini",
"gpt-4.1-mini",
"gpt-4.1",
].filter(Boolean) as string[];
let lastError: unknown;
for (const modelName of candidates) {
try {
const result = await generateText({
model: openai(modelName),
system,
prompt,
});
return {
model: modelName,
answer: result.text,
usedTransactions: context.totals.transactionCount,
usedBalance: {
income: context.totals.income,
expenses: context.totals.expenses,
balance: context.totals.balance,
},
};
} catch (error) {
lastError = error;
}
}
const message =
lastError instanceof Error
? lastError.message
: "Unbekannter Fehler bei der KI-Anfrage";
throw new Error(`KI-Anfrage fehlgeschlagen: ${message}`);
},
});

View File

@@ -64,8 +64,14 @@ export default defineSchema({
.index("by_user_effmonth", ["userId", "effectiveMonth"])
.index("by_user_category", ["userId", "categoryId"])
.index("by_user_account", ["userId", "accountId"])
.index("by_user_account_booking", ["userId", "accountId", "bookingDate"])
.index("by_user_account_effmonth", ["userId", "accountId", "effectiveMonth"])
.index("by_user_dedup", ["userId", "dedupHash"])
.index("by_user_extref", ["userId", "externalRef"]),
.index("by_user_extref", ["userId", "externalRef"])
.searchIndex("search_description", {
searchField: "description",
filterFields: ["userId"],
}),
loans: defineTable({
userId: v.id("users"),

View File

@@ -39,6 +39,7 @@ export const list = query({
from: v.optional(v.string()),
to: v.optional(v.string()),
categoryIds: v.optional(v.array(v.id("categories"))),
withoutCategory: v.optional(v.boolean()),
accountId: v.optional(v.id("accounts")),
type: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
pendingOnly: v.optional(v.boolean()),
@@ -50,48 +51,57 @@ export const list = query({
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
let q = ctx.db
.query("transactions")
.withIndex("by_user_booking", (q) => q.eq("userId", userId))
.order("desc");
const result = await q.paginate(args.paginationOpts);
let page = result.page;
if (args.from) {
page = page.filter((tx) => !tx.bookingDate || tx.bookingDate >= args.from!);
let q;
if (args.search) {
q = ctx.db
.query("transactions")
.withSearchIndex("search_description", (sq) =>
sq.search("description", args.search!).eq("userId", userId),
);
} else {
q = ctx.db
.query("transactions")
.withIndex("by_user_booking", (iq) => {
if (args.from && args.to) {
return iq.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to);
}
if (args.from) {
return iq.eq("userId", userId).gte("bookingDate", args.from);
}
if (args.to) {
return iq.eq("userId", userId).lte("bookingDate", args.to);
}
return iq.eq("userId", userId);
})
.order("desc");
}
if (args.to) {
page = page.filter((tx) => !tx.bookingDate || tx.bookingDate <= args.to!);
if (args.pendingOnly) {
q = q.filter((f) => f.eq(f.field("isPending"), true));
}
if (args.accountId) {
page = page.filter((tx) => tx.accountId === args.accountId);
}
if (args.pendingOnly) {
page = page.filter((tx) => tx.isPending);
q = q.filter((f) => f.eq(f.field("accountId"), args.accountId));
}
if (args.type === "einnahme") {
page = page.filter((tx) => tx.amount > 0);
q = q.filter((f) => f.gt(f.field("amount"), 0));
}
if (args.type === "ausgabe") {
page = page.filter((tx) => tx.amount < 0);
q = q.filter((f) => f.lt(f.field("amount"), 0));
}
if (args.categoryIds && args.categoryIds.length > 0) {
const set = new Set(args.categoryIds);
page = page.filter((tx) => tx.categoryId && set.has(tx.categoryId));
}
if (args.search) {
const s = args.search.toLowerCase();
page = page.filter(
(tx) =>
tx.description.toLowerCase().includes(s) ||
(tx.counterparty?.toLowerCase().includes(s) ?? false) ||
(tx.rawText?.toLowerCase().includes(s) ?? false),
q = q.filter((f) =>
f.or(...args.categoryIds!.map((id) => f.eq(f.field("categoryId"), id))),
);
}
if (args.withoutCategory) {
q = q.filter((f) => f.eq(f.field("categoryId"), undefined));
}
const result = await q.paginate(args.paginationOpts);
return {
page,
page: result.page,
isDone: result.isDone,
continueCursor: result.continueCursor,
};