Files
finanzen/convex/savingsChat.ts

1905 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { action, internalQuery, query } from "./_generated/server";
import { v } from "convex/values";
import { generateText, stepCountIs, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { internal } from "./_generated/api";
import { z } from "zod";
import { addMonthsToMonthKey, bookingMonth, monthKeyFromBasis } 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 DEFAULT_TOOL_ROW_LIMIT = 50;
const MAX_TOOL_ROW_LIMIT = 200;
const MAX_TOOL_RANGE_MONTHS = 18;
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 };
toolTrace: ToolTrace[];
};
type ToolTrace = { name: string; inputSummary: string; resultSummary: string };
type TransactionTypeFilter = "income" | "expense";
type AgentToolScope = ChatContextArgs;
type TransactionToolArgs = {
scope: AgentToolScope;
from?: string;
to?: string;
accountId?: Id<"accounts">;
accountName?: string;
categoryIds?: Id<"categories">[];
categoryNames?: string[];
search?: string;
type?: TransactionTypeFilter;
limit?: number;
};
type ToolTransactionContext = {
from: string;
to: string;
basis: AgentToolScope["basis"];
accountId?: Id<"accounts">;
accountName?: string;
categories: Doc<"categories">[];
accounts: Doc<"accounts">[];
categoryById: Map<Id<"categories">, Doc<"categories">>;
accountById: Map<Id<"accounts">, Doc<"accounts">>;
transactions: Doc<"transactions">[];
};
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 bereitgestellten Werkzeuge und deren Ergebnisse als Finanzkontext.",
"Rufe Werkzeuge auf, wenn du Umsätze, Zusammenfassungen oder Prognosen brauchst; erfinde keine Beträge.",
"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.",
"Nenne keine internen IDs und keine Rohdatenfelder.",
"Verwende keine Links, keine HTML-Tags und keine Emojis.",
].join(" ");
}
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),
});
const toolTraceValidator = v.object({
name: v.string(),
inputSummary: v.string(),
resultSummary: v.string(),
});
const toolScopeValidator = v.object(contextArgsValidator);
const transactionTypeFilterValidator = v.union(v.literal("income"), v.literal("expense"));
const transactionToolArgsValidator = {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
categoryIds: v.optional(v.array(v.id("categories"))),
categoryNames: v.optional(v.array(v.string())),
search: v.optional(v.string()),
type: v.optional(transactionTypeFilterValidator),
limit: v.optional(v.number()),
};
const safeTransactionRowValidator = v.object({
date: v.string(),
bookingDate: v.optional(v.string()),
effectiveMonth: v.optional(v.string()),
description: v.string(),
counterparty: v.optional(v.string()),
amount: v.number(),
categoryName: v.string(),
accountName: v.string(),
isPending: v.boolean(),
});
const monthlyTrendValidator = v.object({
month: v.string(),
income: v.number(),
expenses: v.number(),
balance: v.number(),
});
const categoryBreakdownValidator = v.object({
categoryId: v.optional(v.id("categories")),
name: v.string(),
amount: v.number(),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
});
const safeCategoryBreakdownValidator = v.object({
name: v.string(),
amount: v.number(),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
});
const accountInsightValidator = v.object({
name: v.string(),
type: v.string(),
currency: v.string(),
isArchived: v.boolean(),
openingBalance: v.number(),
transactionCount: v.number(),
balance: v.number(),
});
const categoryInsightValidator = v.object({
name: v.string(),
kind: v.union(v.literal("einnahme"), v.literal("ausgabe")),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
isSystem: v.boolean(),
transactionCount: v.number(),
amount: v.number(),
shareOfExpenses: v.optional(v.number()),
});
const recurringPatternValidator = v.object({
label: v.string(),
counterparty: v.optional(v.string()),
categoryName: v.optional(v.string()),
months: v.array(v.string()),
occurrenceCount: v.number(),
averageAmount: v.number(),
minAmount: v.number(),
maxAmount: v.number(),
frequency: v.literal("monthly"),
lastDate: v.string(),
nextExpectedMonth: v.string(),
});
const anomalyValidator = v.object({
kind: v.union(v.literal("amount_spike"), v.literal("missing_recurring")),
label: v.string(),
month: v.string(),
amount: v.number(),
expectedAmount: v.number(),
severity: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
});
const topCounterpartyValidator = v.object({
name: v.string(),
count: v.number(),
amount: v.number(),
});
const summarySnapshotValidator = v.object({
from: v.string(),
to: v.string(),
totals: totalsValidator,
fixedCosts: v.number(),
variableCosts: v.number(),
monthlyTrend: v.array(monthlyTrendValidator),
categoryBreakdown: v.array(safeCategoryBreakdownValidator),
});
const periodDeltasValidator = v.object({
income: v.number(),
expenses: v.number(),
balance: v.number(),
fixedCosts: v.number(),
variableCosts: v.number(),
});
const categoryDeltaValidator = v.object({
name: v.string(),
currentAmount: v.number(),
previousAmount: v.number(),
delta: v.number(),
});
const fixedCostItemValidator = v.object({
label: v.string(),
averageAmount: v.number(),
occurrenceCount: v.number(),
months: v.array(v.string()),
});
const fixedCostForecastMonthValidator = v.object({
month: v.string(),
totalFixedCosts: v.number(),
});
const savingsDriverValidator = v.object({
name: v.string(),
amount: v.number(),
shareOfExpenses: v.number(),
});
const savingsLeverValidator = v.object({
label: v.string(),
monthlyImpact: v.number(),
});
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 parseMonthIndex(month: string) {
const [year, monthNumber] = month.split("-").map(Number);
return year * 12 + monthNumber;
}
function rangeMonthSpan(from: string, to: string) {
return parseMonthIndex(to.slice(0, 7)) - parseMonthIndex(from.slice(0, 7)) + 1;
}
function clampToolLimit(limit: number | undefined) {
if (limit === undefined) return DEFAULT_TOOL_ROW_LIMIT;
return Math.max(1, Math.min(MAX_TOOL_ROW_LIMIT, Math.floor(limit)));
}
function normalizeToolRange(scope: AgentToolScope, from?: string, to?: string) {
const range = {
from: from?.trim() || scope.from,
to: to?.trim() || scope.to,
};
if (range.from > range.to) {
throw new Error("Ungültiger Zeitraum: von-Datum liegt nach bis-Datum.");
}
if (rangeMonthSpan(range.from, range.to) > MAX_TOOL_RANGE_MONTHS) {
throw new Error(`Der Tool-Zeitraum darf maximal ${MAX_TOOL_RANGE_MONTHS} Monate umfassen.`);
}
return range;
}
async function loadNameMaps(ctx: QueryCtx, userId: Id<"users">) {
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();
return {
categories,
accounts,
categoryById: new Map(categories.map((category) => [category._id, category])),
accountById: new Map(accounts.map((account) => [account._id, account])),
};
}
function findAccountIdByName(
accounts: Doc<"accounts">[],
accountName: string | undefined,
): Id<"accounts"> | undefined {
const normalized = accountName?.trim().toLocaleLowerCase("de-DE");
if (!normalized) return undefined;
return accounts.find((account) => account.name.toLocaleLowerCase("de-DE") === normalized)?._id;
}
function categoryNameSet(categoryNames: string[] | undefined) {
const normalized = categoryNames
?.map((name) => name.trim().toLocaleLowerCase("de-DE"))
.filter(Boolean);
return normalized && normalized.length > 0 ? new Set(normalized) : null;
}
function transactionMatchesToolFilters(
tx: Doc<"transactions">,
args: TransactionToolArgs,
context: Pick<ToolTransactionContext, "categoryById" | "accountById">,
) {
if (args.type === "income" && tx.amount <= 0) return false;
if (args.type === "expense" && tx.amount >= 0) return false;
if (args.categoryIds && args.categoryIds.length > 0) {
const categoryId = tx.categoryId;
if (!categoryId || !args.categoryIds.includes(categoryId)) return false;
}
const categoriesByName = categoryNameSet(args.categoryNames);
if (categoriesByName) {
const name = tx.categoryId ? context.categoryById.get(tx.categoryId)?.name : "Ohne Kategorie";
if (!categoriesByName.has((name ?? "Ohne Kategorie").toLocaleLowerCase("de-DE"))) {
return false;
}
}
const search = args.search?.trim().toLocaleLowerCase("de-DE");
if (search) {
const categoryName = tx.categoryId ? context.categoryById.get(tx.categoryId)?.name : "";
const accountName = tx.accountId ? context.accountById.get(tx.accountId)?.name : "";
const haystack = [
tx.description,
tx.counterparty,
categoryName,
accountName,
]
.filter(Boolean)
.join(" ")
.toLocaleLowerCase("de-DE");
if (!haystack.includes(search)) return false;
}
return true;
}
async function buildToolTransactionContext(
ctx: QueryCtx,
userId: Id<"users">,
args: TransactionToolArgs,
): Promise<ToolTransactionContext> {
const maps = await loadNameMaps(ctx, userId);
const range = normalizeToolRange(args.scope, args.from, args.to);
const accountId = args.accountId ?? findAccountIdByName(maps.accounts, args.accountName) ?? args.scope.accountId;
const account = accountId ? maps.accountById.get(accountId) : undefined;
const transactions = (await loadMatchingTransactions(ctx, userId, {
from: range.from,
to: range.to,
accountId,
basis: args.scope.basis,
})).filter((tx) => transactionMatchesToolFilters(tx, args, maps));
return {
...maps,
...range,
basis: args.scope.basis,
accountId,
accountName: account?.name,
transactions,
};
}
function safeTransactionRow(
tx: Doc<"transactions">,
context: Pick<ToolTransactionContext, "categoryById" | "accountById">,
) {
return {
date: tx.valueDate || tx.bookingDate || tx.effectiveMonth || "n/a",
bookingDate: tx.bookingDate,
effectiveMonth: tx.effectiveMonth,
description: tx.description,
counterparty: tx.counterparty,
amount: Math.round(tx.amount * 100) / 100,
categoryName: tx.categoryId
? context.categoryById.get(tx.categoryId)?.name ?? "Ohne Kategorie"
: "Ohne Kategorie",
accountName: tx.accountId
? context.accountById.get(tx.accountId)?.name ?? "Ohne Konto"
: "Ohne Konto",
isPending: tx.isPending,
};
}
function roundMoney(value: number) {
return Math.round(value * 100) / 100;
}
function summarizeTransactions(context: ToolTransactionContext) {
const monthlyMap = new Map<string, { income: number; expenses: number }>();
const categoryMap = new Map<
string,
{ categoryId?: Id<"categories">; name: string; amount: number; block?: "wiederkehrend" | "variabel" }
>();
let fixedCosts = 0;
let variableCosts = 0;
for (const tx of context.transactions) {
const month = monthKeyFromBasis(tx, context.basis);
if (month) {
const monthEntry = monthlyMap.get(month) ?? { income: 0, expenses: 0 };
if (tx.amount > 0) monthEntry.income += tx.amount;
if (tx.amount < 0) monthEntry.expenses += tx.amount;
monthlyMap.set(month, monthEntry);
}
if (tx.amount >= 0) continue;
const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined;
if (category?.block === "wiederkehrend") fixedCosts += tx.amount;
if (category?.block === "variabel") variableCosts += tx.amount;
const key = tx.categoryId ?? "none";
const categoryEntry = categoryMap.get(key) ?? {
categoryId: tx.categoryId,
name: category?.name ?? "Ohne Kategorie",
amount: 0,
block: category?.block,
};
categoryEntry.amount += tx.amount;
categoryMap.set(key, categoryEntry);
}
return {
totals: calculateTotals(context.transactions),
fixedCosts: roundMoney(fixedCosts),
variableCosts: roundMoney(variableCosts),
monthlyTrend: [...monthlyMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, data]) => ({
month,
income: roundMoney(data.income),
expenses: roundMoney(data.expenses),
balance: roundMoney(data.income + data.expenses),
})),
categoryBreakdown: [...categoryMap.values()]
.sort((a, b) => a.amount - b.amount)
.map((entry) => ({ ...entry, amount: roundMoney(entry.amount) })),
};
}
function roundRatio(value: number) {
return Math.round(value * 1000) / 1000;
}
function summarizeSnapshot(
context: ToolTransactionContext,
summary: ReturnType<typeof summarizeTransactions>,
) {
return {
from: context.from,
to: context.to,
totals: summary.totals,
fixedCosts: summary.fixedCosts,
variableCosts: summary.variableCosts,
monthlyTrend: summary.monthlyTrend,
categoryBreakdown: summary.categoryBreakdown.map((entry) => ({
name: entry.name,
amount: entry.amount,
...(entry.block ? { block: entry.block } : {}),
})),
};
}
function normalizedText(value: string | undefined) {
return value?.trim().toLocaleLowerCase("de-DE") ?? "";
}
function recurringLabelForTransaction(
tx: Doc<"transactions">,
context: Pick<ToolTransactionContext, "categoryById">,
) {
const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined;
return tx.description.trim() || tx.counterparty?.trim() || category?.name || "Unbekannt";
}
function dateForTransaction(tx: Doc<"transactions">) {
return tx.valueDate || tx.bookingDate || tx.effectiveMonth || "n/a";
}
function monthIndexesAreConsecutive(months: string[]) {
if (months.length < 2) return false;
for (let index = 1; index < months.length; index++) {
if (parseMonthIndex(months[index]) - parseMonthIndex(months[index - 1]) !== 1) {
return false;
}
}
return true;
}
function amountSeriesIsStable(amounts: number[]) {
const average = amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length;
const maxDeviation = Math.max(...amounts.map((amount) => Math.abs(amount - average)));
return maxDeviation <= Math.max(Math.abs(average) * 0.2, 5);
}
type RecurringDetectionOptions = {
beforeMonth?: string;
expensesOnly?: boolean;
fixedCategoriesOnly?: boolean;
includeUncategorized?: boolean;
};
function detectRecurringPatterns(
context: ToolTransactionContext,
options: RecurringDetectionOptions = {},
) {
const groups = new Map<
string,
{
label: string;
counterparty?: string;
categoryName?: string;
amountsByMonth: Map<string, number>;
lastDate: string;
firstCreationTime: number;
}
>();
for (const tx of context.transactions) {
if (tx.amount === 0) continue;
if (options.expensesOnly && tx.amount >= 0) continue;
if (!options.includeUncategorized && !tx.categoryId) continue;
const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined;
if (options.fixedCategoriesOnly && category?.block !== "wiederkehrend") continue;
const month = monthKeyFromBasis(tx, context.basis);
if (!month || (options.beforeMonth && month >= options.beforeMonth)) continue;
const label = recurringLabelForTransaction(tx, context);
const key = [
normalizedText(label),
normalizedText(tx.counterparty),
tx.categoryId ?? "none",
tx.amount > 0 ? "income" : "expense",
].join("|");
const entry = groups.get(key) ?? {
label,
counterparty: tx.counterparty,
categoryName: category?.name,
amountsByMonth: new Map<string, number>(),
lastDate: dateForTransaction(tx),
firstCreationTime: tx._creationTime,
};
entry.amountsByMonth.set(month, (entry.amountsByMonth.get(month) ?? 0) + tx.amount);
if (dateForTransaction(tx) > entry.lastDate) entry.lastDate = dateForTransaction(tx);
entry.firstCreationTime = Math.min(entry.firstCreationTime, tx._creationTime);
groups.set(key, entry);
}
return [...groups.values()]
.map((entry) => {
const monthAmounts = [...entry.amountsByMonth.entries()].sort(([a], [b]) =>
a.localeCompare(b),
);
const months = monthAmounts.map(([month]) => month);
const amounts = monthAmounts.map(([, amount]) => amount);
if (months.length < 2 || !monthIndexesAreConsecutive(months) || !amountSeriesIsStable(amounts)) {
return null;
}
const averageAmount = roundMoney(amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length);
return {
label: entry.label,
counterparty: entry.counterparty,
categoryName: entry.categoryName,
months,
occurrenceCount: months.length,
averageAmount,
minAmount: roundMoney(Math.min(...amounts)),
maxAmount: roundMoney(Math.max(...amounts)),
frequency: "monthly" as const,
lastDate: entry.lastDate,
nextExpectedMonth: addMonthsToMonthKey(months[months.length - 1], 1),
};
})
.filter((pattern): pattern is NonNullable<typeof pattern> => pattern !== null)
.sort(
(a, b) =>
b.occurrenceCount - a.occurrenceCount ||
a.label.localeCompare(b.label, "de-DE") ||
a.lastDate.localeCompare(b.lastDate),
);
}
function buildCategoryAmountMap(summary: ReturnType<typeof summarizeTransactions>) {
return new Map(summary.categoryBreakdown.map((entry) => [entry.name, entry.amount]));
}
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 getTransactionsTool = internalQuery({
args: transactionToolArgsValidator,
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
totalCount: v.number(),
hasMore: v.boolean(),
totals: totalsValidator,
rows: v.array(safeTransactionRowValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const limit = clampToolLimit(args.limit);
const rows = context.transactions
.slice(0, limit)
.map((tx) => safeTransactionRow(tx, context));
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
totalCount: context.transactions.length,
hasMore: context.transactions.length > limit,
totals: calculateTotals(context.transactions),
rows,
};
},
});
export const summarizeSpendingTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
categoryIds: v.optional(v.array(v.id("categories"))),
categoryNames: v.optional(v.array(v.string())),
search: v.optional(v.string()),
type: v.optional(transactionTypeFilterValidator),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
totals: totalsValidator,
fixedCosts: v.number(),
variableCosts: v.number(),
monthlyTrend: v.array(monthlyTrendValidator),
categoryBreakdown: v.array(categoryBreakdownValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const summary = summarizeTransactions(context);
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
...summary,
};
},
});
export const forecastCashflowTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
horizonMonths: v.optional(v.number()),
asOf: v.optional(v.string()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
baselineMonths: v.array(v.string()),
excludedPartialMonth: v.union(v.string(), v.null()),
monthlyAverage: v.object({
income: v.number(),
expenses: v.number(),
balance: v.number(),
}),
projection: v.array(monthlyTrendValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const summary = summarizeTransactions(context);
const asOfMonth = (args.asOf ?? new Date().toISOString().slice(0, 10)).slice(0, 7);
const baseline = summary.monthlyTrend.filter((month) => month.month < asOfMonth);
const excludedPartialMonth = summary.monthlyTrend.some((month) => month.month === asOfMonth)
? asOfMonth
: null;
const horizonMonths = Math.max(1, Math.min(3, Math.floor(args.horizonMonths ?? 3)));
const denominator = baseline.length || 1;
const monthlyAverage = {
income: roundMoney(baseline.reduce((sum, month) => sum + month.income, 0) / denominator),
expenses: roundMoney(baseline.reduce((sum, month) => sum + month.expenses, 0) / denominator),
balance: roundMoney(baseline.reduce((sum, month) => sum + month.balance, 0) / denominator),
};
const projection = Array.from({ length: horizonMonths }, (_, index) => ({
month: addMonthsToMonthKey(asOfMonth, index + 1),
...monthlyAverage,
}));
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
baselineMonths: baseline.map((month) => month.month),
excludedPartialMonth,
monthlyAverage,
projection,
};
},
});
export const getAccountsTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
includeArchived: v.optional(v.boolean()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accounts: v.array(accountInsightValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const maps = await loadNameMaps(ctx, userId);
const range = normalizeToolRange(args.scope, args.from, args.to);
const accountId = args.accountId ?? findAccountIdByName(maps.accounts, args.accountName) ?? args.scope.accountId;
const transactions = await loadMatchingTransactions(ctx, userId, {
...range,
accountId,
basis: args.scope.basis,
});
const transactionsByAccount = new Map<Id<"accounts">, Doc<"transactions">[]>();
for (const tx of transactions) {
if (!tx.accountId) continue;
const list = transactionsByAccount.get(tx.accountId) ?? [];
list.push(tx);
transactionsByAccount.set(tx.accountId, list);
}
const accounts = maps.accounts
.filter((account) => {
if (accountId && account._id !== accountId) return false;
return args.includeArchived === true || !account.isArchived;
})
.sort((a, b) => Number(a.isArchived) - Number(b.isArchived) || a.name.localeCompare(b.name, "de-DE"))
.map((account) => {
const accountTransactions = transactionsByAccount.get(account._id) ?? [];
return {
name: account.name,
type: account.type,
currency: account.currency,
isArchived: account.isArchived,
openingBalance: roundMoney(account.openingBalance),
transactionCount: accountTransactions.length,
balance: calculateTotals(accountTransactions).balance,
};
});
return {
from: range.from,
to: range.to,
basis: args.scope.basis,
accounts,
};
},
});
export const getCategoriesTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
categoryIds: v.optional(v.array(v.id("categories"))),
categoryNames: v.optional(v.array(v.string())),
search: v.optional(v.string()),
type: v.optional(transactionTypeFilterValidator),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
categories: v.array(categoryInsightValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const totalExpenses = Math.abs(calculateTotals(context.transactions).expenses);
const sortOrderByCategory = new Map(context.categories.map((category) => [category._id, category.sortOrder]));
const categoryRows = new Map<
string,
{
categoryId?: Id<"categories">;
name: string;
kind: "einnahme" | "ausgabe";
block?: "wiederkehrend" | "variabel";
isSystem: boolean;
transactionCount: number;
amount: number;
}
>();
for (const tx of context.transactions) {
const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined;
const key = tx.categoryId ?? "none";
const existing = categoryRows.get(key) ?? {
categoryId: tx.categoryId,
name: category?.name ?? "Ohne Kategorie",
kind: category?.kind ?? (tx.amount >= 0 ? "einnahme" : "ausgabe"),
block: category?.block,
isSystem: category?.isSystem ?? false,
transactionCount: 0,
amount: 0,
};
existing.transactionCount += 1;
existing.amount += tx.amount;
if (!category && existing.amount < 0) existing.kind = "ausgabe";
categoryRows.set(key, existing);
}
const categories = [...categoryRows.values()]
.sort((a, b) => {
const aSort = a.categoryId ? sortOrderByCategory.get(a.categoryId) ?? 0 : Number.MAX_SAFE_INTEGER;
const bSort = b.categoryId ? sortOrderByCategory.get(b.categoryId) ?? 0 : Number.MAX_SAFE_INTEGER;
return aSort - bSort || a.name.localeCompare(b.name, "de-DE");
})
.map((entry) => ({
name: entry.name,
kind: entry.kind,
...(entry.block ? { block: entry.block } : {}),
isSystem: entry.isSystem,
transactionCount: entry.transactionCount,
amount: roundMoney(entry.amount),
...(entry.amount < 0 && totalExpenses > 0
? { shareOfExpenses: roundRatio(Math.abs(entry.amount) / totalExpenses) }
: {}),
}));
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
categories,
};
},
});
export const getUncategorizedTransactionsTool = internalQuery({
args: transactionToolArgsValidator,
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
totalCount: v.number(),
hasMore: v.boolean(),
totals: totalsValidator,
topCounterparties: v.array(topCounterpartyValidator),
rows: v.array(safeTransactionRowValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, {
...args,
categoryIds: undefined,
categoryNames: undefined,
});
const limit = clampToolLimit(args.limit);
const uncategorized = context.transactions.filter((tx) => !tx.categoryId);
const counterpartyMap = new Map<string, { count: number; amount: number }>();
for (const tx of uncategorized) {
const name = tx.counterparty?.trim() || tx.description.trim() || "Unbekannt";
const entry = counterpartyMap.get(name) ?? { count: 0, amount: 0 };
entry.count += 1;
entry.amount += tx.amount;
counterpartyMap.set(name, entry);
}
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
totalCount: uncategorized.length,
hasMore: uncategorized.length > limit,
totals: calculateTotals(uncategorized),
topCounterparties: [...counterpartyMap.entries()]
.map(([name, entry]) => ({ name, count: entry.count, amount: roundMoney(entry.amount) }))
.sort((a, b) => b.count - a.count || Math.abs(b.amount) - Math.abs(a.amount) || a.name.localeCompare(b.name, "de-DE"))
.slice(0, 5),
rows: uncategorized.slice(0, limit).map((tx) => safeTransactionRow(tx, context)),
};
},
});
export const comparePeriodsTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
compareFrom: v.string(),
compareTo: v.string(),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
categoryIds: v.optional(v.array(v.id("categories"))),
categoryNames: v.optional(v.array(v.string())),
search: v.optional(v.string()),
type: v.optional(transactionTypeFilterValidator),
},
returns: v.object({
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
current: summarySnapshotValidator,
previous: summarySnapshotValidator,
deltas: periodDeltasValidator,
categoryDeltas: v.array(categoryDeltaValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const currentContext = await buildToolTransactionContext(ctx, userId, args);
const previousContext = await buildToolTransactionContext(ctx, userId, {
scope: args.scope,
from: args.compareFrom,
to: args.compareTo,
accountId: args.accountId,
accountName: args.accountName,
categoryIds: args.categoryIds,
categoryNames: args.categoryNames,
search: args.search,
type: args.type,
});
const currentSummary = summarizeTransactions(currentContext);
const previousSummary = summarizeTransactions(previousContext);
const currentCategoryMap = buildCategoryAmountMap(currentSummary);
const previousCategoryMap = buildCategoryAmountMap(previousSummary);
const categoryNames = new Set([...currentCategoryMap.keys(), ...previousCategoryMap.keys()]);
return {
basis: currentContext.basis,
accountName: currentContext.accountName,
current: summarizeSnapshot(currentContext, currentSummary),
previous: summarizeSnapshot(previousContext, previousSummary),
deltas: {
income: roundMoney(currentSummary.totals.income - previousSummary.totals.income),
expenses: roundMoney(currentSummary.totals.expenses - previousSummary.totals.expenses),
balance: roundMoney(currentSummary.totals.balance - previousSummary.totals.balance),
fixedCosts: roundMoney(currentSummary.fixedCosts - previousSummary.fixedCosts),
variableCosts: roundMoney(currentSummary.variableCosts - previousSummary.variableCosts),
},
categoryDeltas: [...categoryNames]
.map((name) => {
const currentAmount = currentCategoryMap.get(name) ?? 0;
const previousAmount = previousCategoryMap.get(name) ?? 0;
return {
name,
currentAmount,
previousAmount,
delta: roundMoney(currentAmount - previousAmount),
};
})
.sort((a, b) => a.delta - b.delta || a.name.localeCompare(b.name, "de-DE")),
};
},
});
export const explainSavingsRateTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
categoryIds: v.optional(v.array(v.id("categories"))),
categoryNames: v.optional(v.array(v.string())),
search: v.optional(v.string()),
type: v.optional(transactionTypeFilterValidator),
},
returns: v.object({
from: v.string(),
to: v.string(),
income: v.number(),
expenses: v.number(),
savedAmount: v.number(),
savingsRate: v.union(v.number(), v.null()),
fixedCosts: v.number(),
variableCosts: v.number(),
transactionCount: v.number(),
drivers: v.array(savingsDriverValidator),
levers: v.array(savingsLeverValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const summary = summarizeTransactions(context);
const totalExpenses = Math.abs(summary.totals.expenses);
const drivers = summary.categoryBreakdown.slice(0, 3).map((entry) => ({
name: entry.name,
amount: entry.amount,
shareOfExpenses: totalExpenses > 0 ? roundRatio(Math.abs(entry.amount) / totalExpenses) : 0,
}));
const levers = [];
if (summary.variableCosts < 0) {
levers.push({
label: "Variable Ausgaben um 10% senken",
monthlyImpact: roundMoney(Math.abs(summary.variableCosts) * 0.1),
});
}
if (summary.fixedCosts < 0) {
levers.push({
label: "Fixkosten um 5% senken",
monthlyImpact: roundMoney(Math.abs(summary.fixedCosts) * 0.05),
});
}
return {
from: context.from,
to: context.to,
income: summary.totals.income,
expenses: summary.totals.expenses,
savedAmount: summary.totals.balance,
savingsRate: summary.totals.income > 0 ? roundRatio(summary.totals.balance / summary.totals.income) : null,
fixedCosts: summary.fixedCosts,
variableCosts: summary.variableCosts,
transactionCount: summary.totals.transactionCount,
drivers,
levers,
};
},
});
export const detectRecurringTransactionsTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
categoryIds: v.optional(v.array(v.id("categories"))),
categoryNames: v.optional(v.array(v.string())),
search: v.optional(v.string()),
type: v.optional(transactionTypeFilterValidator),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
patterns: v.array(recurringPatternValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
patterns: detectRecurringPatterns(context),
};
},
});
export const forecastFixedCostsTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
horizonMonths: v.optional(v.number()),
asOf: v.optional(v.string()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
items: v.array(fixedCostItemValidator),
forecast: v.array(fixedCostForecastMonthValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const fixedGroups = new Map<
string,
{ label: string; amountsByMonth: Map<string, number> }
>();
for (const tx of context.transactions) {
if (tx.amount >= 0 || !tx.categoryId) continue;
const category = context.categoryById.get(tx.categoryId);
if (category?.block !== "wiederkehrend") continue;
const month = monthKeyFromBasis(tx, context.basis);
if (!month) continue;
const label = recurringLabelForTransaction(tx, context);
const key = [normalizedText(label), tx.categoryId, normalizedText(tx.counterparty)].join("|");
const entry = fixedGroups.get(key) ?? { label, amountsByMonth: new Map<string, number>() };
entry.amountsByMonth.set(month, (entry.amountsByMonth.get(month) ?? 0) + tx.amount);
fixedGroups.set(key, entry);
}
const items = [...fixedGroups.values()]
.map((entry) => {
const monthAmounts = [...entry.amountsByMonth.entries()].sort(([a], [b]) =>
a.localeCompare(b),
);
const months = monthAmounts.map(([month]) => month);
const amounts = monthAmounts.map(([, amount]) => amount);
return {
label: entry.label,
averageAmount: roundMoney(amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length),
occurrenceCount: months.length,
months,
};
})
.filter((item) => item.months.length > 0)
.sort((a, b) => a.averageAmount - b.averageAmount || a.label.localeCompare(b.label, "de-DE"));
const horizonMonths = Math.max(1, Math.min(6, Math.floor(args.horizonMonths ?? 3)));
const asOfMonth = (args.asOf ?? context.to).slice(0, 7);
const totalFixedCosts = roundMoney(items.reduce((sum, item) => sum + item.averageAmount, 0));
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
items,
forecast: Array.from({ length: horizonMonths }, (_, index) => ({
month: addMonthsToMonthKey(asOfMonth, index + 1),
totalFixedCosts,
})),
};
},
});
export const findAnomaliesTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
asOf: v.optional(v.string()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
anomalies: v.array(anomalyValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const targetMonth = (args.asOf ?? context.to).slice(0, 7);
const amountByCategoryMonth = new Map<string, Map<string, number>>();
const amountByRecurringLabelMonth = new Map<string, Map<string, number>>();
for (const tx of context.transactions) {
const month = monthKeyFromBasis(tx, context.basis);
if (!month) continue;
const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined;
const categoryName = category?.name ?? "Ohne Kategorie";
const categoryMonthMap = amountByCategoryMonth.get(categoryName) ?? new Map<string, number>();
categoryMonthMap.set(month, (categoryMonthMap.get(month) ?? 0) + tx.amount);
amountByCategoryMonth.set(categoryName, categoryMonthMap);
if (tx.categoryId) {
const label = recurringLabelForTransaction(tx, context);
const recurringMonthMap = amountByRecurringLabelMonth.get(label) ?? new Map<string, number>();
recurringMonthMap.set(month, (recurringMonthMap.get(month) ?? 0) + tx.amount);
amountByRecurringLabelMonth.set(label, recurringMonthMap);
}
}
const anomalies: Array<{
kind: "amount_spike" | "missing_recurring";
label: string;
month: string;
amount: number;
expectedAmount: number;
severity: "low" | "medium" | "high";
}> = [];
for (const [label, amountsByMonth] of amountByCategoryMonth.entries()) {
const currentAmount = roundMoney(amountsByMonth.get(targetMonth) ?? 0);
if (currentAmount >= 0) continue;
const baseline = [...amountsByMonth.entries()]
.filter(([month, amount]) => month < targetMonth && amount < 0)
.map(([, amount]) => amount);
if (baseline.length < 2) continue;
const expectedAmount = roundMoney(baseline.reduce((sum, amount) => sum + amount, 0) / baseline.length);
const ratio = Math.abs(currentAmount) / Math.max(Math.abs(expectedAmount), 1);
if (ratio >= 2 && Math.abs(currentAmount - expectedAmount) >= 100) {
anomalies.push({
kind: "amount_spike",
label,
month: targetMonth,
amount: currentAmount,
expectedAmount,
severity: ratio >= 3 ? "high" : "medium",
});
}
}
for (const pattern of detectRecurringPatterns(context, { beforeMonth: targetMonth })) {
if (pattern.nextExpectedMonth !== targetMonth) continue;
const actualAmount = roundMoney(amountByRecurringLabelMonth.get(pattern.label)?.get(targetMonth) ?? 0);
if (actualAmount === 0) {
anomalies.push({
kind: "missing_recurring",
label: pattern.label,
month: targetMonth,
amount: 0,
expectedAmount: pattern.averageAmount,
severity: "medium",
});
}
}
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
anomalies: anomalies.sort(
(a, b) =>
(b.severity === "high" ? 2 : b.severity === "medium" ? 1 : 0) -
(a.severity === "high" ? 2 : a.severity === "medium" ? 1 : 0) ||
a.label.localeCompare(b.label, "de-DE"),
),
};
},
});
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),
),
};
},
});
function unknownRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function maybeString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function maybeNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function summarizeToolInput(input: unknown) {
const record = unknownRecord(input);
const parts = [];
const from = maybeString(record.from);
const to = maybeString(record.to);
const search = maybeString(record.search);
const limit = maybeNumber(record.limit);
const horizonMonths = maybeNumber(record.horizonMonths);
const type = maybeString(record.type);
const categoryNames = Array.isArray(record.categoryNames)
? record.categoryNames.filter((name): name is string => typeof name === "string")
: [];
if (from || to) parts.push(`${from ?? "?"} bis ${to ?? "?"}`);
if (search) parts.push(`Suche "${search}"`);
if (categoryNames.length > 0) parts.push(`Kategorien ${categoryNames.join(", ")}`);
if (type) parts.push(type === "income" ? "Einnahmen" : "Ausgaben");
if (horizonMonths) parts.push(`${horizonMonths} Monate Prognose`);
if (limit) parts.push(`Limit ${limit}`);
return parts.length > 0 ? parts.join(", ") : "Standard-Kontext";
}
function totalsFromOutput(output: Record<string, unknown>) {
const totals = unknownRecord(output.totals);
return {
count: maybeNumber(output.totalCount) ?? maybeNumber(totals.transactionCount) ?? 0,
balance: maybeNumber(totals.balance) ?? 0,
};
}
function countLabel(count: number, singular: string, plural: string) {
return `${count} ${count === 1 ? singular : plural}`;
}
function summarizeToolOutput(toolName: string, output: unknown) {
const record = unknownRecord(output);
if (toolName === "get_transactions") {
const totals = totalsFromOutput(record);
const hasMore = record.hasMore === true;
return `${totals.count} Umsätze, Saldo ${formatEuro(totals.balance)}, ${
hasMore ? "weitere vorhanden" : "vollständig"
}`;
}
if (toolName === "summarize_spending") {
const totals = totalsFromOutput(record);
const categoryCount = Array.isArray(record.categoryBreakdown)
? record.categoryBreakdown.length
: 0;
return `${totals.count} Umsätze, Saldo ${formatEuro(totals.balance)}, ${categoryCount} Kategorien`;
}
if (toolName === "forecast_cashflow") {
const projectionCount = Array.isArray(record.projection) ? record.projection.length : 0;
const average = unknownRecord(record.monthlyAverage);
const balance = maybeNumber(average.balance) ?? 0;
return `Prognose ${projectionCount} Monate, durchschnittlicher Saldo ${formatEuro(balance)}`;
}
if (toolName === "get_accounts") {
const count = Array.isArray(record.accounts) ? record.accounts.length : 0;
return `${countLabel(count, "Konto", "Konten")} ausgewertet`;
}
if (toolName === "get_categories") {
const count = Array.isArray(record.categories) ? record.categories.length : 0;
return `${countLabel(count, "Kategorie", "Kategorien")} ausgewertet`;
}
if (toolName === "detect_recurring_transactions") {
const count = Array.isArray(record.patterns) ? record.patterns.length : 0;
return `${count} ${count === 1 ? "wiederkehrendes Muster" : "wiederkehrende Muster"} erkannt`;
}
if (toolName === "find_anomalies") {
const count = Array.isArray(record.anomalies) ? record.anomalies.length : 0;
return `${count} ${count === 1 ? "Auffälligkeit" : "Auffälligkeiten"} erkannt`;
}
if (toolName === "get_uncategorized_transactions") {
const count = maybeNumber(record.totalCount) ?? 0;
const hasMore = record.hasMore === true;
return `${count} unklassifizierte Umsätze, ${hasMore ? "weitere vorhanden" : "vollständig"}`;
}
if (toolName === "compare_periods") {
const deltas = unknownRecord(record.deltas);
const balance = maybeNumber(deltas.balance) ?? 0;
return `Periodenvergleich, Saldo-Differenz ${formatEuro(balance)}`;
}
if (toolName === "forecast_fixed_costs") {
const count = Array.isArray(record.forecast) ? record.forecast.length : 0;
return `Fixkosten-Prognose ${count} Monate`;
}
if (toolName === "explain_savings_rate") {
const savingsRate = maybeNumber(record.savingsRate) ?? 0;
const savedAmount = maybeNumber(record.savedAmount) ?? 0;
return `Sparquote ${(savingsRate * 100).toFixed(1)}%, gespart ${formatEuro(savedAmount)}`;
}
return "Werkzeug ausgeführt";
}
export function buildToolTraceFromSteps(steps: unknown[]): ToolTrace[] {
const trace: ToolTrace[] = [];
for (const step of steps) {
const stepRecord = unknownRecord(step);
const toolResults = Array.isArray(stepRecord.toolResults) ? stepRecord.toolResults : [];
for (const result of toolResults) {
const resultRecord = unknownRecord(result);
const name = maybeString(resultRecord.toolName);
if (!name) continue;
trace.push({
name,
inputSummary: summarizeToolInput(resultRecord.input),
resultSummary: summarizeToolOutput(name, resultRecord.output),
});
}
}
return trace;
}
const transactionToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
categoryNames: z.array(z.string()).optional().describe("Optionale Kategorienamen wie Lebensmittel oder Miete."),
search: z.string().optional().describe("Optionaler Suchtext für Beschreibung, Gegenpartei, Konto oder Kategorie."),
type: z.enum(["income", "expense"]).optional().describe("Optional nur Einnahmen oder Ausgaben abrufen."),
limit: z.number().int().min(1).max(MAX_TOOL_ROW_LIMIT).optional().describe("Maximale Anzahl Umsatzzeilen."),
});
const summaryToolInputSchema = transactionToolInputSchema.omit({ limit: true });
const forecastToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum der historischen Basis im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum der historischen Basis im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
horizonMonths: z.number().int().min(1).max(3).optional().describe("Anzahl der zu prognostizierenden Monate, 1 bis 3."),
});
const accountToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls nur ein Konto betrachtet werden soll."),
includeArchived: z.boolean().optional().describe("Archivierte Konten einbeziehen."),
});
const recurringToolInputSchema = summaryToolInputSchema;
const anomalyToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
asOf: z.string().optional().describe("Stichtag für erwartete Muster im Format YYYY-MM-DD."),
});
const uncategorizedToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
search: z.string().optional().describe("Optionaler Suchtext für Beschreibung oder Gegenpartei."),
type: z.enum(["income", "expense"]).optional().describe("Optional nur Einnahmen oder Ausgaben abrufen."),
limit: z.number().int().min(1).max(MAX_TOOL_ROW_LIMIT).optional().describe("Maximale Anzahl Umsatzzeilen."),
});
const comparePeriodsToolInputSchema = z.object({
from: z.string().optional().describe("Startdatum des aktuellen Zeitraums im Format YYYY-MM-DD."),
to: z.string().optional().describe("Enddatum des aktuellen Zeitraums im Format YYYY-MM-DD."),
compareFrom: z.string().describe("Startdatum des Vergleichszeitraums im Format YYYY-MM-DD."),
compareTo: z.string().describe("Enddatum des Vergleichszeitraums im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
categoryNames: z.array(z.string()).optional().describe("Optionale Kategorienamen für den Vergleich."),
search: z.string().optional().describe("Optionaler Suchtext für beide Zeiträume."),
type: z.enum(["income", "expense"]).optional().describe("Optional nur Einnahmen oder Ausgaben vergleichen."),
});
const fixedCostsForecastToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum der historischen Fixkostenbasis im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum der historischen Fixkostenbasis im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
horizonMonths: z.number().int().min(1).max(6).optional().describe("Anzahl der zu prognostizierenden Monate, 1 bis 6."),
asOf: z.string().optional().describe("Stichtag für den Start der Prognose im Format YYYY-MM-DD."),
});
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(),
}),
toolTrace: v.array(toolTraceValidator),
}),
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 scope: AgentToolScope = {
from: args.from,
to: args.to,
accountId: args.accountId,
basis: args.basis,
};
const selectedSummary: {
totalCount: number;
totals: { income: number; expenses: number; balance: number; transactionCount: number };
accountName?: string;
} = await ctx.runQuery(internal.savingsChat.getTransactionsTool, {
scope,
limit: 1,
});
const lastMessages = args.messages
.map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content }))
.slice(-MAX_CONVERSATION_MESSAGES);
const system = buildSystemPrompt({
from: args.from,
to: args.to,
basis: args.basis,
accountName: selectedSummary.accountName,
});
const savingsTools = {
get_transactions: tool({
description:
"Ruft passende Umsätze read-only ab. Nutze dieses Tool für Detailfragen, Suche nach Gegenparteien/Beschreibungen oder Belege einzelner Aussagen. Es liefert exakte Summen und nur begrenzte, sanitizte Zeilen.",
inputSchema: transactionToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.getTransactionsTool, {
scope,
...input,
}),
}),
summarize_spending: tool({
description:
"Berechnet read-only exakte Summen, Monatsverläufe, Kategorien sowie fixe und variable Ausgaben für den ausgewählten oder angegebenen Zeitraum.",
inputSchema: summaryToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.summarizeSpendingTool, {
scope,
...input,
}),
}),
forecast_cashflow: tool({
description:
"Erstellt eine deterministische Cashflow-Prognose für 1 bis 3 kommende Monate aus vollständigen historischen Monaten. Nutze es für Sparrate, Monatsüberschuss und kurzfristige Prognosen.",
inputSchema: forecastToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.forecastCashflowTool, {
scope,
...input,
}),
}),
get_accounts: tool({
description:
"Listet read-only Konten mit Typ, Währung, Archivstatus, Startsaldo, Umsatzanzahl und Zeitraumssaldo. Nutze es für Fragen nach Konten, Konto-Scope oder Datenabdeckung.",
inputSchema: accountToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.getAccountsTool, {
scope,
...input,
}),
}),
get_categories: tool({
description:
"Listet read-only Kategorien mit Art, Fix/Variabel-Block, Umsatzanzahl, Summe und Ausgabenanteil im Zeitraum. Nutze es für Kategorie- und Budgetstrukturfragen.",
inputSchema: summaryToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.getCategoriesTool, {
scope,
...input,
}),
}),
detect_recurring_transactions: tool({
description:
"Erkennt deterministisch monatlich wiederkehrende Muster nach Beschreibung, Gegenpartei, Kategorie und stabiler Betragshöhe. Nutze es für Miete, Gehalt, Abos und regelmäßige Abbuchungen.",
inputSchema: recurringToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.detectRecurringTransactionsTool, {
scope,
...input,
}),
}),
find_anomalies: tool({
description:
"Findet read-only auffällige Betragsausreißer und fehlende erwartete wiederkehrende Buchungen gegenüber historischen Mustern.",
inputSchema: anomalyToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.findAnomaliesTool, {
scope,
...input,
}),
}),
get_uncategorized_transactions: tool({
description:
"Ruft bounded und sanitizt unklassifizierte Umsätze mit Summen und Top-Gegenparteien ab. Nutze es für Datenqualität und Fragen nach fehlenden Kategorien.",
inputSchema: uncategorizedToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.getUncategorizedTransactionsTool, {
scope,
...input,
}),
}),
compare_periods: tool({
description:
"Vergleicht zwei Zeiträume deterministisch mit Totals, Monatsverlauf, Kategorie-Deltas und Fix/Variabel-Deltas.",
inputSchema: comparePeriodsToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.comparePeriodsTool, {
scope,
...input,
}),
}),
forecast_fixed_costs: tool({
description:
"Prognostiziert wiederkehrende Fixkosten für 1 bis 6 Monate aus Fixkosten-Kategorien und stabilen historischen Monatsmustern.",
inputSchema: fixedCostsForecastToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.forecastFixedCostsTool, {
scope,
...input,
}),
}),
explain_savings_rate: tool({
description:
"Berechnet Sparquote, gesparten Betrag, fixe und variable Kostenquote, Haupttreiber und konkrete Hebel aus exakten Aggregaten.",
inputSchema: summaryToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.explainSavingsRateTool, {
scope,
...input,
}),
}),
};
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,
messages: lastMessages,
tools: savingsTools,
stopWhen: stepCountIs(5),
});
return {
model: modelName,
answer: result.text,
usedTransactions: selectedSummary.totals.transactionCount,
usedBalance: {
income: selectedSummary.totals.income,
expenses: selectedSummary.totals.expenses,
balance: selectedSummary.totals.balance,
},
toolTrace: buildToolTraceFromSteps(result.steps),
};
} catch (error) {
lastError = error;
}
}
const message =
lastError instanceof Error
? lastError.message
: "Unbekannter Fehler bei der KI-Anfrage";
throw new Error(`KI-Anfrage fehlgeschlagen: ${message}`);
},
});