Files
finanzen/convex/savingsChat.ts
2026-06-23 21:13:02 +02:00

2959 lines
105 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, internalMutation, 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 { ActionCtx, MutationCtx, 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[];
sources: ChatSource[];
citations: ChatCitation[];
};
type ToolTrace = { name: string; inputSummary: string; resultSummary: string };
type ChatSource = { id: string; title: string; description?: string };
type ChatCitation = { marker: string; sourceId: string };
type TransactionTypeFilter = "income" | "expense";
type CategoryFilterStatus = "resolved" | "unresolved" | "ambiguous";
type CategoryFilterDiagnostic = {
requested: string;
status: CategoryFilterStatus;
matchedName?: string;
suggestions: string[];
};
type CategoryFilterInfo = { diagnostics: CategoryFilterDiagnostic[] };
type CategoryFilterResolution = {
categoryIds: Id<"categories">[];
includeUncategorized: boolean;
categoryFilter?: CategoryFilterInfo;
hasNameFilter: boolean;
};
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">[];
categoryFilter?: CategoryFilterInfo;
};
function formatEuro(value: number): string {
return `${value.toFixed(2)}`;
}
function buildSystemPrompt(context: { from: string; to: string; basis: string; today: 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.",
"Nutze get_categories ohne Kategorie-Filter, wenn du vorhandene Kategorienamen prüfen musst.",
"Wenn ein Werkzeug categoryFilter-Diagnosen mit unresolved oder ambiguous liefert, nenne die Kategorie als nicht sicher gefunden und verwende keine gesicherte 0-Euro-Aussage.",
"Monatsvorschauen sind Schätzungen; kennzeichne sie klar als Prognose.",
"Wenn du einen Änderungs-Vorschlag vorbereitest, sage ausdrücklich, dass er erst über den Button in der Oberfläche angewendet wird.",
"Antworte auf Deutsch, kurz und handlungsorientiert.",
`Zeitraum: ${context.from} bis ${context.to}.`,
`Basis: ${context.basis}.`,
`Heute: ${context.today}.`,
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 | MutationCtx,
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 sourceValidator = v.object({
id: v.string(),
title: v.string(),
description: v.optional(v.string()),
});
const citationValidator = v.object({
marker: v.string(),
sourceId: 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 categoryFilterDiagnosticValidator = v.object({
requested: v.string(),
status: v.union(v.literal("resolved"), v.literal("unresolved"), v.literal("ambiguous")),
matchedName: v.optional(v.string()),
suggestions: v.array(v.string()),
});
const categoryFilterValidator = v.object({
diagnostics: v.array(categoryFilterDiagnosticValidator),
});
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(),
});
const previewDriverValidator = v.object({
label: v.string(),
amount: v.number(),
kind: v.union(v.literal("fixed"), v.literal("variable"), v.literal("actual")),
});
const categoryHealthCategoryValidator = 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(),
});
const similarCategoryValidator = v.object({
names: v.array(v.string()),
reason: v.string(),
});
const categorySuggestionExampleValidator = v.object({
date: v.string(),
description: v.string(),
counterparty: v.optional(v.string()),
amount: v.number(),
accountName: v.string(),
});
const categorySuggestionValidator = v.object({
categoryName: v.string(),
confidence: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
matchCount: v.number(),
amount: v.number(),
examples: v.array(categorySuggestionExampleValidator),
reason: v.string(),
});
const proposalPreviewRowValidator = v.object({
date: v.optional(v.string()),
description: v.string(),
amount: v.optional(v.number()),
currentCategoryName: v.optional(v.string()),
targetCategoryName: v.optional(v.string()),
action: v.optional(v.string()),
});
const proposalResultValidator = v.object({
planId: v.id("agentActionPlans"),
kind: v.union(v.literal("bulk_recategory"), v.literal("category_changes")),
status: v.literal("pending"),
summary: v.string(),
affectedCount: v.number(),
expiresAt: v.number(),
previewRows: v.array(proposalPreviewRowValidator),
});
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 | MutationCtx, 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;
}
const CATEGORY_TOKEN_STOPWORDS = new Set(["und"]);
function normalizeCategoryText(value: string) {
return value
.trim()
.toLocaleLowerCase("de-DE")
.replace(/ß/g, "ss")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ae/g, "a")
.replace(/oe/g, "o")
.replace(/ue/g, "u");
}
function stemCategoryToken(token: string) {
for (const ending of ["innen", "ern", "en", "er", "es", "e", "n", "s"]) {
if (token.length - ending.length >= 4 && token.endsWith(ending)) {
return token.slice(0, -ending.length);
}
}
return token;
}
function categoryTokens(value: string) {
return normalizeCategoryText(value)
.replace(/&/g, " und ")
.replace(/[^a-z0-9]+/g, " ")
.split(" ")
.map((token) => token.trim())
.filter((token) => token && !CATEGORY_TOKEN_STOPWORDS.has(token))
.map(stemCategoryToken);
}
function categoryTokenKey(tokens: string[]) {
return [...new Set(tokens)].sort((a, b) => a.localeCompare(b, "de-DE")).join(" ");
}
function categoryFilterResult(categoryFilter: CategoryFilterInfo | undefined) {
return categoryFilter ? { categoryFilter } : {};
}
function resolveCategoryNames(
categories: Doc<"categories">[],
categoryNames: string[] | undefined,
): CategoryFilterResolution {
const requestedNames = categoryNames?.map((name) => name.trim()).filter(Boolean) ?? [];
if (requestedNames.length === 0) {
return { categoryIds: [], includeUncategorized: false, hasNameFilter: false };
}
const indexedCategories = [
...categories.map((category) => ({
categoryId: category._id as Id<"categories"> | undefined,
name: category.name,
sortOrder: category.sortOrder,
})),
{ categoryId: undefined, name: "Ohne Kategorie", sortOrder: Number.MAX_SAFE_INTEGER },
].map((category) => {
const tokens = categoryTokens(category.name);
return { ...category, tokens, tokenSet: new Set(tokens), key: categoryTokenKey(tokens) };
});
const categoryIds: Id<"categories">[] = [];
const diagnostics: CategoryFilterDiagnostic[] = [];
let includeUncategorized = false;
for (const requested of requestedNames) {
const requestedTokens = categoryTokens(requested);
const requestedKey = categoryTokenKey(requestedTokens);
const exactMatches = requestedKey
? indexedCategories.filter((entry) => entry.key === requestedKey)
: [];
const containmentMatches =
exactMatches.length > 0
? exactMatches
: indexedCategories.filter(
(entry) =>
requestedTokens.length > 0 &&
requestedTokens.every((token) => entry.tokenSet.has(token)),
);
if (containmentMatches.length === 1) {
const matchedCategory = containmentMatches[0];
if (matchedCategory.categoryId) {
categoryIds.push(matchedCategory.categoryId);
} else {
includeUncategorized = true;
}
diagnostics.push({
requested,
status: "resolved",
matchedName: matchedCategory.name,
suggestions: [],
});
continue;
}
if (containmentMatches.length > 1) {
diagnostics.push({
requested,
status: "ambiguous",
suggestions: containmentMatches
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b, "de-DE"))
.slice(0, 5),
});
continue;
}
const suggestions = indexedCategories
.map((entry) => ({
name: entry.name,
sortOrder: entry.sortOrder,
score: requestedTokens.filter((token) => entry.tokenSet.has(token)).length,
}))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || a.sortOrder - b.sortOrder || a.name.localeCompare(b.name, "de-DE"))
.map((entry) => entry.name)
.slice(0, 5);
diagnostics.push({
requested,
status: "unresolved",
suggestions,
});
}
return {
categoryIds: [...new Set(categoryIds)],
includeUncategorized,
categoryFilter: { diagnostics },
hasNameFilter: true,
};
}
function transactionMatchesToolFilters(
tx: Doc<"transactions">,
args: TransactionToolArgs,
context: Pick<ToolTransactionContext, "categoryById" | "accountById">,
categoryIds: Set<Id<"categories">> | null,
includeUncategorized: boolean,
) {
if (args.type === "income" && tx.amount <= 0) return false;
if (args.type === "expense" && tx.amount >= 0) return false;
if (categoryIds) {
const categoryId = tx.categoryId;
if (!categoryId) return includeUncategorized;
if (!categoryIds.has(categoryId)) 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 | MutationCtx,
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 resolvedCategoryFilter = resolveCategoryNames(maps.categories, args.categoryNames);
const explicitCategoryIds = args.categoryIds ?? [];
const categoryIds = [...new Set([...explicitCategoryIds, ...resolvedCategoryFilter.categoryIds])];
const categoryIdFilter =
explicitCategoryIds.length > 0 || resolvedCategoryFilter.hasNameFilter
? new Set(categoryIds)
: null;
const transactions = (await loadMatchingTransactions(ctx, userId, {
from: range.from,
to: range.to,
accountId,
basis: args.scope.basis,
})).filter((tx) =>
transactionMatchesToolFilters(
tx,
args,
maps,
categoryIdFilter,
resolvedCategoryFilter.includeUncategorized,
),
);
return {
...maps,
...range,
basis: args.scope.basis,
accountId,
accountName: account?.name,
transactions,
categoryFilter: resolvedCategoryFilter.categoryFilter,
};
}
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 daysInMonth(month: string) {
const [year, monthNumber] = month.split("-").map(Number);
return new Date(year, monthNumber, 0).getDate();
}
function monthStart(month: string) {
return `${month}-01`;
}
function monthEnd(month: string) {
return `${month}-${String(daysInMonth(month)).padStart(2, "0")}`;
}
function dayOfMonth(date: string) {
return Number(date.slice(8, 10));
}
function previousMonthStart(month: string, monthsBack: number) {
return monthStart(addMonthsToMonthKey(month, -monthsBack));
}
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()),
categoryFilter: v.optional(categoryFilterValidator),
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,
...categoryFilterResult(context.categoryFilter),
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()),
categoryFilter: v.optional(categoryFilterValidator),
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,
...categoryFilterResult(context.categoryFilter),
...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()),
categoryFilter: v.optional(categoryFilterValidator),
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,
...categoryFilterResult(context.categoryFilter),
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 previewMonthEndSpendingTool = internalQuery({
args: {
scope: toolScopeValidator,
today: v.string(),
accountName: v.optional(v.string()),
},
returns: v.object({
month: v.string(),
today: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
actualIncome: v.number(),
actualExpenses: v.number(),
actualBalance: v.number(),
predictedRemainingFixed: v.number(),
predictedRemainingVariable: v.number(),
predictedRemainingExpenses: v.number(),
expectedMonthExpenses: v.number(),
projectedMonthEndBalance: v.number(),
confidence: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
drivers: v.array(previewDriverValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const month = args.today.slice(0, 7);
const day = Math.max(1, Math.min(dayOfMonth(args.today), daysInMonth(month)));
const remainingDays = Math.max(0, daysInMonth(month) - day);
const currentContext = await buildToolTransactionContext(ctx, userId, {
scope: args.scope,
from: monthStart(month),
to: args.today,
accountName: args.accountName,
});
const historyContext = await buildToolTransactionContext(ctx, userId, {
scope: args.scope,
from: previousMonthStart(month, 3),
to: monthEnd(addMonthsToMonthKey(month, -1)),
accountName: args.accountName,
});
const currentTotals = calculateTotals(currentContext.transactions);
const fixedSeenThisMonth = new Set<Id<"categories">>();
let currentVariableExpenses = 0;
for (const tx of currentContext.transactions) {
if (tx.amount >= 0 || !tx.categoryId) continue;
const category = currentContext.categoryById.get(tx.categoryId);
if (category?.block === "wiederkehrend") fixedSeenThisMonth.add(tx.categoryId);
if (category?.block === "variabel") currentVariableExpenses += tx.amount;
}
const fixedByCategoryMonth = new Map<
Id<"categories">,
{ categoryName: string; amountsByMonth: Map<string, number> }
>();
let historicalVariableTotal = 0;
const historicalMonths = new Set<string>();
for (const tx of historyContext.transactions) {
if (tx.amount >= 0) continue;
const monthKey = monthKeyFromBasis(tx, historyContext.basis);
if (!monthKey) continue;
historicalMonths.add(monthKey);
const category = tx.categoryId ? historyContext.categoryById.get(tx.categoryId) : undefined;
if (category?.block === "wiederkehrend" && tx.categoryId) {
const entry = fixedByCategoryMonth.get(tx.categoryId) ?? {
categoryName: category.name,
amountsByMonth: new Map<string, number>(),
};
entry.amountsByMonth.set(monthKey, (entry.amountsByMonth.get(monthKey) ?? 0) + tx.amount);
fixedByCategoryMonth.set(tx.categoryId, entry);
}
if (category?.block === "variabel") {
historicalVariableTotal += tx.amount;
}
}
const fixedDrivers = [...fixedByCategoryMonth.entries()]
.filter(([categoryId]) => !fixedSeenThisMonth.has(categoryId))
.map(([, entry]) => ({
label: entry.categoryName,
amount: roundMoney(
[...entry.amountsByMonth.values()].reduce((sum, amount) => sum + amount, 0) /
entry.amountsByMonth.size,
),
}))
.filter((entry) => entry.amount < 0)
.sort((a, b) => a.amount - b.amount || a.label.localeCompare(b.label, "de-DE"));
const predictedRemainingFixed = roundMoney(
fixedDrivers.reduce((sum, entry) => sum + entry.amount, 0),
);
const predictedRemainingVariable =
currentVariableExpenses < 0
? roundMoney((currentVariableExpenses / day) * remainingDays)
: roundMoney(
(historicalVariableTotal / Math.max(1, historicalMonths.size) / daysInMonth(month)) *
remainingDays,
);
const predictedRemainingExpenses = roundMoney(predictedRemainingFixed + predictedRemainingVariable);
const confidence: "low" | "medium" | "high" =
day >= 15 && historicalMonths.size >= 2
? "high"
: historicalMonths.size > 0 || day >= 7
? "medium"
: "low";
const drivers = [
...(fixedDrivers.length > 0
? [
{
label: `Noch erwartete Fixkosten: ${fixedDrivers.map((entry) => entry.label).join(", ")}`,
amount: predictedRemainingFixed,
kind: "fixed" as const,
},
]
: []),
...(predictedRemainingVariable < 0
? [
{
label:
currentVariableExpenses < 0
? "Variable Ausgaben auf Basis des laufenden Monats"
: "Variable Ausgaben auf Basis historischer Monate",
amount: predictedRemainingVariable,
kind: "variable" as const,
},
]
: []),
];
return {
month,
today: args.today,
basis: args.scope.basis,
accountName: currentContext.accountName,
actualIncome: currentTotals.income,
actualExpenses: currentTotals.expenses,
actualBalance: currentTotals.balance,
predictedRemainingFixed,
predictedRemainingVariable,
predictedRemainingExpenses,
expectedMonthExpenses: roundMoney(currentTotals.expenses + predictedRemainingExpenses),
projectedMonthEndBalance: roundMoney(currentTotals.balance + predictedRemainingExpenses),
confidence,
drivers,
};
},
});
export const inspectCategoryHealthTool = 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()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
totalCategories: v.number(),
uncategorizedCount: v.number(),
uncategorizedAmount: v.number(),
topUncategorizedCounterparties: v.array(topCounterpartyValidator),
unusedCategories: v.array(categoryHealthCategoryValidator),
similarCategories: v.array(similarCategoryValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const usedCategoryIds = new Set(
context.transactions.flatMap((tx) => (tx.categoryId ? [tx.categoryId] : [])),
);
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);
}
const similarCategories = [];
for (let a = 0; a < context.categories.length; a++) {
for (let b = a + 1; b < context.categories.length; b++) {
const first = context.categories[a];
const second = context.categories[b];
const firstTokens = new Set(categoryTokens(first.name));
const secondTokens = new Set(categoryTokens(second.name));
const overlap = [...firstTokens].filter((token) => secondTokens.has(token));
const subset =
overlap.length > 0 &&
(overlap.length === firstTokens.size || overlap.length === secondTokens.size);
if (!subset) continue;
similarCategories.push({
names: [first.name, second.name].sort((x, y) => x.localeCompare(y, "de-DE")),
reason: "Ähnliche Kategoriebegriffe",
});
}
}
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
totalCategories: context.categories.length,
uncategorizedCount: uncategorized.length,
uncategorizedAmount: roundMoney(uncategorized.reduce((sum, tx) => sum + tx.amount, 0)),
topUncategorizedCounterparties: [...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),
unusedCategories: context.categories
.filter((category) => !usedCategoryIds.has(category._id))
.map((category) => ({
name: category.name,
kind: category.kind,
block: category.block,
isSystem: category.isSystem,
})),
similarCategories,
};
},
});
export const suggestCategoryForTransactionsTool = internalQuery({
args: {
...transactionToolArgsValidator,
onlyUncategorized: v.optional(v.boolean()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
suggestions: v.array(categorySuggestionValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const limit = clampToolLimit(args.limit);
const candidates = context.transactions
.filter((tx) => (args.onlyUncategorized ? !tx.categoryId : true))
.slice(0, limit);
const history = await loadMatchingTransactions(ctx, userId, {
from: previousMonthStart(context.from.slice(0, 7), 12),
to: context.from,
accountId: context.accountId,
basis: context.basis,
});
const suggestionMap = new Map<
Id<"categories">,
{ categoryName: string; matchCount: number; amount: number; examples: ReturnType<typeof safeTransactionRow>[] }
>();
for (const tx of candidates) {
const txText = [tx.description, tx.counterparty].map(normalizedText).filter(Boolean);
const matches = history.filter((historical) => {
if (!historical.categoryId) return false;
const historicalText = [historical.description, historical.counterparty].map(normalizedText).filter(Boolean);
return txText.some((text) => text && historicalText.includes(text));
});
for (const match of matches) {
const categoryId = match.categoryId;
if (!categoryId) continue;
const category = context.categoryById.get(categoryId);
if (!category) continue;
const entry = suggestionMap.get(categoryId) ?? {
categoryName: category.name,
matchCount: 0,
amount: 0,
examples: [],
};
entry.matchCount += 1;
entry.amount += tx.amount;
if (entry.examples.length < 3) entry.examples.push(safeTransactionRow(tx, context));
suggestionMap.set(categoryId, entry);
}
}
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
suggestions: [...suggestionMap.values()]
.map((entry) => ({
categoryName: entry.categoryName,
confidence: entry.matchCount >= 1 ? "high" as const : "medium" as const,
matchCount: entry.examples.length,
amount: roundMoney(entry.amount),
examples: entry.examples.map((row) => ({
date: row.date,
description: row.description,
counterparty: row.counterparty,
amount: row.amount,
accountName: row.accountName,
})),
reason: "Ähnliche frühere Umsätze waren bereits dieser Kategorie zugeordnet.",
}))
.sort((a, b) => b.matchCount - a.matchCount || Math.abs(b.amount) - Math.abs(a.amount))
.slice(0, 5),
};
},
});
async function requireOwnedChatSession(
ctx: MutationCtx,
sessionId: Id<"chatSessions">,
userId: Id<"users">,
) {
const session = await ctx.db.get(sessionId);
if (!session) throw new Error("Chat nicht gefunden");
if (session.userId !== userId) throw new Error("Nicht autorisiert");
if (session.isDeleted) throw new Error("Chat nicht gefunden");
}
export const proposeBulkRecategoryTool = internalMutation({
args: {
scope: toolScopeValidator,
sessionId: v.id("chatSessions"),
targetCategoryName: v.string(),
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
search: v.optional(v.string()),
onlyUncategorized: v.optional(v.boolean()),
limit: v.optional(v.number()),
now: v.optional(v.number()),
},
returns: proposalResultValidator,
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await requireOwnedChatSession(ctx, args.sessionId, userId);
const targetCategory = await ctx.db
.query("categories")
.withIndex("by_user_name", (index) =>
index.eq("userId", userId).eq("name", args.targetCategoryName.trim()),
)
.unique();
if (!targetCategory) throw new Error("Zielkategorie nicht gefunden");
const context = await buildToolTransactionContext(ctx, userId, args);
const limit = Math.min(100, clampToolLimit(args.limit));
const transactions = context.transactions
.filter((tx) => (args.onlyUncategorized ? !tx.categoryId : true))
.filter((tx) => tx.categoryId !== targetCategory._id)
.slice(0, limit);
if (transactions.length === 0) throw new Error("Keine passenden Umsätze gefunden");
const operations = transactions.map((tx) => ({
type: "set_transaction_category" as const,
transactionId: tx._id,
fromCategoryId: tx.categoryId,
toCategoryId: targetCategory._id,
}));
const previewRows = transactions.slice(0, 10).map((tx) => ({
date: dateForTransaction(tx),
description: tx.description,
amount: roundMoney(tx.amount),
currentCategoryName: tx.categoryId
? context.categoryById.get(tx.categoryId)?.name ?? "Ohne Kategorie"
: "Ohne Kategorie",
targetCategoryName: targetCategory.name,
}));
const now = args.now ?? Date.now();
const summary = `${operations.length} Umsatz${operations.length === 1 ? "" : "e"} zur Kategorie ${targetCategory.name} zuordnen`;
const planId = await ctx.db.insert("agentActionPlans", {
userId,
sessionId: args.sessionId,
kind: "bulk_recategory",
status: "pending",
summary,
affectedCount: operations.length,
createdAt: now,
expiresAt: now + 24 * 60 * 60 * 1000,
operations,
previewRows,
});
return {
planId,
kind: "bulk_recategory" as const,
status: "pending" as const,
summary,
affectedCount: operations.length,
expiresAt: now + 24 * 60 * 60 * 1000,
previewRows,
};
},
});
const categoryCreateValidator = 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"))),
color: v.string(),
icon: v.optional(v.string()),
sortOrder: v.number(),
});
const categoryUpdateByNameValidator = v.object({
name: v.string(),
newName: v.optional(v.string()),
kind: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
color: v.optional(v.string()),
icon: v.optional(v.string()),
sortOrder: v.optional(v.number()),
});
export const proposeCategoryChangesTool = internalMutation({
args: {
sessionId: v.id("chatSessions"),
creates: v.optional(v.array(categoryCreateValidator)),
updates: v.optional(v.array(categoryUpdateByNameValidator)),
now: v.optional(v.number()),
},
returns: proposalResultValidator,
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await requireOwnedChatSession(ctx, args.sessionId, userId);
const operations = [];
const previewRows = [];
for (const create of (args.creates ?? []).slice(0, 100)) {
const name = create.name.trim();
if (!name || (create.kind === "ausgabe" && !create.block)) continue;
operations.push({
type: "create_category" as const,
name,
kind: create.kind,
block: create.kind === "ausgabe" ? create.block : undefined,
color: create.color,
icon: create.icon,
sortOrder: create.sortOrder,
});
previewRows.push({
description: name,
action: "Kategorie erstellen",
targetCategoryName: name,
});
}
for (const update of (args.updates ?? []).slice(0, 100 - operations.length)) {
const trimmedNewName = update.newName?.trim();
if (update.newName !== undefined && !trimmedNewName) continue;
const category = await ctx.db
.query("categories")
.withIndex("by_user_name", (index) =>
index.eq("userId", userId).eq("name", update.name.trim()),
)
.unique();
if (!category) continue;
operations.push({
type: "update_category" as const,
categoryId: category._id,
name: trimmedNewName,
kind: update.kind,
block: update.block,
color: update.color,
icon: update.icon,
sortOrder: update.sortOrder,
});
previewRows.push({
description: category.name,
action: "Kategorie aktualisieren",
currentCategoryName: category.name,
targetCategoryName: trimmedNewName ?? category.name,
});
}
if (operations.length === 0) throw new Error("Keine Kategorieänderungen vorbereitet");
const now = args.now ?? Date.now();
const summary = `${operations.length} Kategorieänderung${operations.length === 1 ? "" : "en"} vorbereiten`;
const planId = await ctx.db.insert("agentActionPlans", {
userId,
sessionId: args.sessionId,
kind: "category_changes",
status: "pending",
summary,
affectedCount: operations.length,
createdAt: now,
expiresAt: now + 24 * 60 * 60 * 1000,
operations,
previewRows,
});
return {
planId,
kind: "category_changes" as const,
status: "pending" as const,
summary,
affectedCount: operations.length,
expiresAt: now + 24 * 60 * 60 * 1000,
previewRows,
};
},
});
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()),
categoryFilter: v.optional(categoryFilterValidator),
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,
...categoryFilterResult(currentContext.categoryFilter),
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(),
categoryFilter: v.optional(categoryFilterValidator),
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,
...categoryFilterResult(context.categoryFilter),
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()),
categoryFilter: v.optional(categoryFilterValidator),
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,
...categoryFilterResult(context.categoryFilter),
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 today = maybeString(record.today);
const targetCategoryName = maybeString(record.targetCategoryName);
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 (today) parts.push(`Monatsvorschau für ${today}`);
if (search) parts.push(`Suche "${search}"`);
if (targetCategoryName) parts.push(`Zielkategorie ${targetCategoryName}`);
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 summarizeCategoryFilterDiagnostics(output: Record<string, unknown>) {
const categoryFilter = unknownRecord(output.categoryFilter);
const diagnostics = Array.isArray(categoryFilter.diagnostics)
? categoryFilter.diagnostics.map(unknownRecord)
: [];
const issues = diagnostics
.map((diagnostic) => {
const status = maybeString(diagnostic.status);
if (status !== "unresolved" && status !== "ambiguous") return null;
const requested = maybeString(diagnostic.requested) ?? "unbekannt";
const suggestions = Array.isArray(diagnostic.suggestions)
? diagnostic.suggestions.filter((suggestion): suggestion is string => typeof suggestion === "string")
: [];
const statusLabel = status === "ambiguous" ? "mehrdeutig" : "unklar";
if (suggestions.length === 0) return `${requested} ${statusLabel}`;
const suggestionLabel = suggestions.length === 1 ? "Vorschlag" : "Vorschläge";
return `${requested} ${statusLabel} (${suggestionLabel}: ${suggestions.join(", ")})`;
})
.filter((issue): issue is string => issue !== null);
return issues.length > 0 ? `, Kategorie-Filter: ${issues.join("; ")}` : "";
}
function summarizeToolOutput(toolName: string, output: unknown) {
const record = unknownRecord(output);
const categoryFilterSummary = summarizeCategoryFilterDiagnostics(record);
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"
}${categoryFilterSummary}`;
}
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${categoryFilterSummary}`;
}
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${categoryFilterSummary}`;
}
if (toolName === "detect_recurring_transactions") {
const count = Array.isArray(record.patterns) ? record.patterns.length : 0;
return `${count} ${count === 1 ? "wiederkehrendes Muster" : "wiederkehrende Muster"} erkannt${categoryFilterSummary}`;
}
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)}${categoryFilterSummary}`;
}
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)}${categoryFilterSummary}`;
}
if (toolName === "preview_month_end_spending") {
const month = maybeString(record.month) ?? "unbekannt";
const expectedMonthExpenses = maybeNumber(record.expectedMonthExpenses) ?? 0;
const predictedRemainingExpenses = maybeNumber(record.predictedRemainingExpenses) ?? 0;
return `Monatsvorschau ${month}: erwartet ${formatEuro(expectedMonthExpenses)} Ausgaben, Rest ${formatEuro(predictedRemainingExpenses)}`;
}
if (toolName === "inspect_category_health") {
const uncategorizedCount = maybeNumber(record.uncategorizedCount) ?? 0;
const unusedCategories = Array.isArray(record.unusedCategories) ? record.unusedCategories.length : 0;
const similarCategories = Array.isArray(record.similarCategories) ? record.similarCategories.length : 0;
return `${uncategorizedCount} unklassifizierte Umsätze, ${unusedCategories} ungenutzte Kategorien, ${similarCategories} ähnliche Gruppen`;
}
if (toolName === "suggest_category_for_transactions") {
const count = Array.isArray(record.suggestions) ? record.suggestions.length : 0;
return `${count} ${count === 1 ? "Kategorie-Vorschlag" : "Kategorie-Vorschläge"} gefunden`;
}
if (toolName === "propose_bulk_recategory" || toolName === "propose_category_changes") {
const affectedCount = maybeNumber(record.affectedCount) ?? 0;
const summary = maybeString(record.summary) ?? "Vorschlag vorbereitet";
return `${summary}, ${affectedCount} Änderung${affectedCount === 1 ? "" : "en"} warten auf Bestätigung`;
}
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;
}
export function buildSourcesFromToolTrace(toolTrace: ToolTrace[]): ChatSource[] {
return toolTrace.map((trace, index) => ({
id: `tool-${index + 1}`,
title: trace.name,
description: trace.resultSummary,
}));
}
export function buildCitationsFromSources(sources: ChatSource[]): ChatCitation[] {
return sources.map((source, index) => ({
marker: `${index + 1}`,
sourceId: source.id,
}));
}
function appendMissingCitationMarkers(answer: string, citations: ChatCitation[]): string {
const missingMarkers = citations
.map((citation) => citation.marker)
.filter((marker) => !answer.includes(`[${marker}]`));
if (missingMarkers.length === 0) return answer;
return `${answer.trimEnd()} ${missingMarkers.map((marker) => `[${marker}]`).join(" ")}`;
}
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; Namen werden tolerant gegen &/und, Pluralformen, Umlaute und Groß-/Kleinschreibung aufgelöst."),
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; Namen werden tolerant gegen &/und, Pluralformen, Umlaute und Groß-/Kleinschreibung aufgelöst."),
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."),
});
const monthEndPreviewToolInputSchema = z.object({
today: z.string().describe("Lokales heutiges Datum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
});
const categoryHealthToolInputSchema = 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."),
});
const categorySuggestionToolInputSchema = 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("Suchtext für Beschreibung, Gegenpartei oder Kategorie."),
onlyUncategorized: z.boolean().optional().describe("Nur Umsätze ohne Kategorie vorschlagen."),
limit: z.number().int().min(1).max(MAX_TOOL_ROW_LIMIT).optional().describe("Maximale Anzahl geprüfter Umsatzzeilen."),
});
const bulkRecategoryProposalToolInputSchema = z.object({
targetCategoryName: z.string().describe("Bestehende Zielkategorie, der die gefundenen Umsätze zugeordnet werden sollen."),
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("Suchtext für die zuzuordnenden Umsätze."),
onlyUncategorized: z.boolean().optional().describe("Nur Umsätze ohne Kategorie in den Vorschlag aufnehmen."),
limit: z.number().int().min(1).max(MAX_TOOL_ROW_LIMIT).optional().describe("Maximale Anzahl Änderungen."),
});
const categoryChangeProposalToolInputSchema = z.object({
creates: z.array(z.object({
name: z.string(),
kind: z.enum(["einnahme", "ausgabe"]),
block: z.enum(["wiederkehrend", "variabel"]).optional(),
color: z.string(),
icon: z.string().optional(),
sortOrder: z.number(),
})).optional().describe("Neue Kategorien, die nach UI-Bestätigung erstellt werden sollen."),
updates: z.array(z.object({
name: z.string(),
newName: z.string().optional(),
kind: z.enum(["einnahme", "ausgabe"]).optional(),
block: z.enum(["wiederkehrend", "variabel"]).optional(),
color: z.string().optional(),
icon: z.string().optional(),
sortOrder: z.number().optional(),
})).optional().describe("Bestehende Kategorien nach Name, die nach UI-Bestätigung geändert werden sollen."),
});
async function generateSavingsChatResponse(
ctx: ActionCtx,
args: ChatContextArgs & { messages: ChatMessage[]; today?: string; sessionId?: Id<"chatSessions"> },
): 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 today = args.today ?? new Date().toISOString().slice(0, 10);
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,
today,
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,
}),
}),
inspect_category_health: tool({
description:
"Analysiert read-only Kategoriequalität: unklassifizierte Umsätze, ungenutzte Kategorien, ähnliche Kategorien und Top-Gegenparteien ohne Kategorie.",
inputSchema: categoryHealthToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.inspectCategoryHealthTool, {
scope,
...input,
}),
}),
suggest_category_for_transactions: tool({
description:
"Schlägt read-only Kategorien für passende oder unklassifizierte Umsätze vor. Nutze es vor Änderungs-Vorschlägen, um eine sichere Zielkategorie zu begründen.",
inputSchema: categorySuggestionToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.suggestCategoryForTransactionsTool, {
scope,
...input,
}),
}),
preview_month_end_spending: tool({
description:
"Berechnet eine deterministische Monatsvorschau vom lokalen heutigen Datum bis Monatsende. Nutze dieses Tool für vermutete Ausgaben im Rest des aktuellen Monats.",
inputSchema: monthEndPreviewToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.previewMonthEndSpendingTool, {
scope,
today: input.today || today,
accountName: input.accountName,
}),
}),
propose_bulk_recategory: tool({
description:
"Erstellt nur einen bestätigungspflichtigen Vorschlag, um passende Umsätze einer bestehenden Kategorie zuzuordnen. Das Tool ändert keine Daten direkt.",
inputSchema: bulkRecategoryProposalToolInputSchema,
execute: async (input) => {
if (!args.sessionId) throw new Error("Änderungs-Vorschläge sind nur in gespeicherten Chats möglich.");
return await ctx.runMutation(internal.savingsChat.proposeBulkRecategoryTool, {
scope,
sessionId: args.sessionId,
...input,
});
},
}),
propose_category_changes: tool({
description:
"Erstellt nur einen bestätigungspflichtigen Vorschlag für Kategorie-Erstellung oder Kategorie-Updates. Löschen wird nicht unterstützt.",
inputSchema: categoryChangeProposalToolInputSchema,
execute: async (input) => {
if (!args.sessionId) throw new Error("Änderungs-Vorschläge sind nur in gespeicherten Chats möglich.");
return await ctx.runMutation(internal.savingsChat.proposeCategoryChangesTool, {
sessionId: args.sessionId,
...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),
});
const toolTrace = buildToolTraceFromSteps(result.steps);
const sources = buildSourcesFromToolTrace(toolTrace);
const citations = buildCitationsFromSources(sources);
return {
model: modelName,
answer: appendMissingCitationMarkers(result.text, citations),
usedTransactions: selectedSummary.totals.transactionCount,
usedBalance: {
income: selectedSummary.totals.income,
expenses: selectedSummary.totals.expenses,
balance: selectedSummary.totals.balance,
},
toolTrace,
sources,
citations,
};
} catch (error) {
lastError = error;
}
}
const message =
lastError instanceof Error
? lastError.message
: "Unbekannter Fehler bei der KI-Anfrage";
throw new Error(`KI-Anfrage fehlgeschlagen: ${message}`);
}
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")),
today: v.optional(v.string()),
},
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),
sources: v.array(sourceValidator),
citations: v.array(citationValidator),
}),
handler: async (ctx, args): Promise<ChatAskResult> => {
return await generateSavingsChatResponse(ctx, {
...args,
today: args.today,
messages: args.messages.map((message) => ({
role: normalizeRole(message.role),
content: message.content,
})),
});
},
});
export const sendMessage = action({
args: {
sessionId: v.id("chatSessions"),
content: v.string(),
from: v.string(),
to: v.string(),
accountId: v.optional(v.id("accounts")),
basis: v.union(v.literal("effective"), v.literal("booking")),
today: v.optional(v.string()),
},
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),
sources: v.array(sourceValidator),
citations: v.array(citationValidator),
}),
handler: async (ctx, args): Promise<ChatAskResult> => {
const content = args.content.trim();
if (!content) {
throw new Error("Kein Nutzernachrichttext vorhanden.");
}
await ctx.runMutation(internal.savingsChatHistory.appendUserMessage, {
sessionId: args.sessionId,
content,
});
const messages: ChatMessage[] = await ctx.runQuery(
internal.savingsChatHistory.getRecentMessagesForPrompt,
{
sessionId: args.sessionId,
limit: MAX_CONVERSATION_MESSAGES,
},
);
let response: ChatAskResult;
try {
response = await generateSavingsChatResponse(ctx, {
from: args.from,
to: args.to,
accountId: args.accountId,
basis: args.basis,
today: args.today,
sessionId: args.sessionId,
messages,
});
} catch (error) {
await ctx.runMutation(internal.savingsChatHistory.appendAssistantMessage, {
sessionId: args.sessionId,
content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.",
});
throw error;
}
await ctx.runMutation(internal.savingsChatHistory.appendAssistantMessage, {
sessionId: args.sessionId,
content: response.answer,
toolTrace: response.toolTrace,
sources: response.sources,
citations: response.citations,
});
return response;
},
});