import { action, internalQuery, query } from "./_generated/server"; import { v } from "convex/values"; import { generateText, stepCountIs, tool } from "ai"; import { openai } from "@ai-sdk/openai"; import { internal } from "./_generated/api"; import { z } from "zod"; import { addMonthsToMonthKey, bookingMonth, monthKeyFromBasis } from "./lib/month"; import { requireUserId } from "./lib/helpers"; import type { Doc, Id } from "./_generated/dataModel"; import type { QueryCtx } from "./_generated/server"; type ChatRole = "user" | "assistant"; type ChatMessage = { role: ChatRole; content: string }; const chatMessageValidator = v.object({ role: v.union(v.literal("user"), v.literal("assistant")), content: v.string(), }); const MAX_CONVERSATION_MESSAGES = 20; const DEFAULT_TOOL_ROW_LIMIT = 50; const MAX_TOOL_ROW_LIMIT = 200; const MAX_TOOL_RANGE_MONTHS = 18; type ChatContextArgs = { from: string; to: string; accountId?: Id<"accounts">; basis: "effective" | "booking"; }; type ChatContextSummary = { from: string; to: string; basis: "effective" | "booking"; accountId?: Id<"accounts">; accountName?: string; totals: { transactionCount: number; income: number; expenses: number; balance: number }; isComplete: true; }; type ChatPromptContext = ChatContextSummary & { transactionLines: string[]; }; type ChatAskResult = { model: string; answer: string; usedTransactions: number; usedBalance: { income: number; expenses: number; balance: number }; toolTrace: ToolTrace[]; }; type ToolTrace = { name: string; inputSummary: string; resultSummary: string }; type TransactionTypeFilter = "income" | "expense"; type 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, Doc<"categories">>; accountById: Map, 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; 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.", "Antworte auf Deutsch, kurz und handlungsorientiert.", `Zeitraum: ${context.from} bis ${context.to}.`, `Basis: ${context.basis}.`, context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.", "Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.", "Nenne keine internen IDs und keine Rohdatenfelder.", "Verwende keine Links, keine HTML-Tags und keine Emojis.", ].join(" "); } function normalizeRole(role: ChatRole): "user" | "assistant" { return role; } function sortTransactionsForContext( transactions: Doc<"transactions">[], basis: ChatContextArgs["basis"], ) { return transactions.sort((a, b) => { const aMonth = basis === "effective" ? a.effectiveMonth ?? bookingMonth(a.bookingDate) ?? "" : ""; const bMonth = basis === "effective" ? b.effectiveMonth ?? bookingMonth(b.bookingDate) ?? "" : ""; const aDate = basis === "booking" ? a.bookingDate ?? "" : a.valueDate ?? a.bookingDate ?? ""; const bDate = basis === "booking" ? b.bookingDate ?? "" : b.valueDate ?? b.bookingDate ?? ""; const aKey = `${aMonth}|${aDate}|${a._creationTime}`; const bKey = `${bMonth}|${bDate}|${b._creationTime}`; return bKey.localeCompare(aKey); }); } async function loadMatchingTransactions( ctx: QueryCtx, userId: Id<"users">, args: ChatContextArgs, ): Promise[]> { const monthFrom = args.from.slice(0, 7); const monthTo = args.to.slice(0, 7); const transactions: Doc<"transactions">[] = []; if (args.basis === "effective") { if (args.accountId) { const accountId = args.accountId; const q = ctx.db .query("transactions") .withIndex("by_user_account_effmonth", (index) => index .eq("userId", userId) .eq("accountId", accountId) .gte("effectiveMonth", monthFrom) .lte("effectiveMonth", monthTo), ) .order("desc"); for await (const tx of q) transactions.push(tx); const fallback = ctx.db .query("transactions") .withIndex("by_user_account_booking", (index) => index .eq("userId", userId) .eq("accountId", accountId) .gte("bookingDate", args.from) .lte("bookingDate", args.to), ) .order("desc"); for await (const tx of fallback) { if (tx.effectiveMonth === undefined) transactions.push(tx); } return sortTransactionsForContext(transactions, args.basis); } const q = ctx.db .query("transactions") .withIndex("by_user_effmonth", (index) => index.eq("userId", userId).gte("effectiveMonth", monthFrom).lte("effectiveMonth", monthTo), ) .order("desc"); for await (const tx of q) transactions.push(tx); const fallback = ctx.db .query("transactions") .withIndex("by_user_booking", (index) => index.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to), ) .order("desc"); for await (const tx of fallback) { if (tx.effectiveMonth === undefined) transactions.push(tx); } return sortTransactionsForContext(transactions, args.basis); } if (args.accountId) { const accountId = args.accountId; const q = ctx.db .query("transactions") .withIndex("by_user_account_booking", (index) => index .eq("userId", userId) .eq("accountId", accountId) .gte("bookingDate", args.from) .lte("bookingDate", args.to), ) .order("desc"); for await (const tx of q) transactions.push(tx); return transactions; } const q = ctx.db .query("transactions") .withIndex("by_user_booking", (index) => index.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to), ) .order("desc"); for await (const tx of q) transactions.push(tx); return sortTransactionsForContext(transactions, args.basis); } function calculateTotals(transactions: Doc<"transactions">[]) { const totals = transactions.reduce( (acc, tx) => { if (tx.amount > 0) acc.income += tx.amount; if (tx.amount < 0) acc.expenses += tx.amount; acc.balance += tx.amount; acc.transactionCount += 1; return acc; }, { income: 0, expenses: 0, balance: 0, transactionCount: 0 }, ); return { transactionCount: totals.transactionCount, income: Math.round(totals.income * 100) / 100, expenses: Math.round(totals.expenses * 100) / 100, balance: Math.round(totals.balance * 100) / 100, }; } async function buildContextSummary( ctx: QueryCtx, userId: Id<"users">, args: ChatContextArgs, ): Promise<{ summary: ChatContextSummary; transactions: Doc<"transactions">[] }> { const transactions = await loadMatchingTransactions(ctx, userId, args); const account = args.accountId ? await ctx.db.get(args.accountId) : null; return { summary: { from: args.from, to: args.to, basis: args.basis, accountId: args.accountId, accountName: account?.userId === userId ? account.name : undefined, totals: calculateTotals(transactions), isComplete: true, }, transactions, }; } const contextArgsValidator = { from: v.string(), to: v.string(), accountId: v.optional(v.id("accounts")), basis: v.union(v.literal("effective"), v.literal("booking")), }; const totalsValidator = v.object({ transactionCount: v.number(), income: v.number(), expenses: v.number(), balance: v.number(), }); const contextSummaryValidator = v.object({ from: v.string(), to: v.string(), basis: v.union(v.literal("effective"), v.literal("booking")), accountId: v.optional(v.id("accounts")), accountName: v.optional(v.string()), totals: totalsValidator, isComplete: v.literal(true), }); const toolTraceValidator = v.object({ name: v.string(), inputSummary: v.string(), resultSummary: v.string(), }); const toolScopeValidator = v.object(contextArgsValidator); const transactionTypeFilterValidator = v.union(v.literal("income"), v.literal("expense")); const transactionToolArgsValidator = { scope: toolScopeValidator, from: v.optional(v.string()), to: v.optional(v.string()), accountId: v.optional(v.id("accounts")), accountName: v.optional(v.string()), categoryIds: v.optional(v.array(v.id("categories"))), categoryNames: v.optional(v.array(v.string())), search: v.optional(v.string()), type: v.optional(transactionTypeFilterValidator), limit: v.optional(v.number()), }; const safeTransactionRowValidator = v.object({ date: v.string(), bookingDate: v.optional(v.string()), effectiveMonth: v.optional(v.string()), description: v.string(), counterparty: v.optional(v.string()), amount: v.number(), categoryName: v.string(), accountName: v.string(), isPending: v.boolean(), }); const monthlyTrendValidator = v.object({ month: v.string(), income: v.number(), expenses: v.number(), balance: v.number(), }); const categoryBreakdownValidator = v.object({ categoryId: v.optional(v.id("categories")), name: v.string(), amount: v.number(), block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))), }); const safeCategoryBreakdownValidator = v.object({ name: v.string(), amount: v.number(), block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))), }); const accountInsightValidator = v.object({ name: v.string(), type: v.string(), currency: v.string(), isArchived: v.boolean(), openingBalance: v.number(), transactionCount: v.number(), balance: v.number(), }); const categoryInsightValidator = v.object({ name: v.string(), kind: v.union(v.literal("einnahme"), v.literal("ausgabe")), block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))), isSystem: v.boolean(), transactionCount: v.number(), amount: v.number(), shareOfExpenses: v.optional(v.number()), }); const 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(), }); export const getContext = query({ args: contextArgsValidator, returns: contextSummaryValidator, handler: async (ctx, args): Promise => { const userId = await requireUserId(ctx); const { summary } = await buildContextSummary(ctx, userId, args); return summary; }, }); function parseMonthIndex(month: string) { const [year, monthNumber] = month.split("-").map(Number); return year * 12 + monthNumber; } function rangeMonthSpan(from: string, to: string) { return parseMonthIndex(to.slice(0, 7)) - parseMonthIndex(from.slice(0, 7)) + 1; } function clampToolLimit(limit: number | undefined) { if (limit === undefined) return DEFAULT_TOOL_ROW_LIMIT; return Math.max(1, Math.min(MAX_TOOL_ROW_LIMIT, Math.floor(limit))); } function normalizeToolRange(scope: AgentToolScope, from?: string, to?: string) { const range = { from: from?.trim() || scope.from, to: to?.trim() || scope.to, }; if (range.from > range.to) { throw new Error("Ungültiger Zeitraum: von-Datum liegt nach bis-Datum."); } if (rangeMonthSpan(range.from, range.to) > MAX_TOOL_RANGE_MONTHS) { throw new Error(`Der Tool-Zeitraum darf maximal ${MAX_TOOL_RANGE_MONTHS} Monate umfassen.`); } return range; } async function loadNameMaps(ctx: QueryCtx, userId: Id<"users">) { const categories = await ctx.db .query("categories") .withIndex("by_user", (index) => index.eq("userId", userId)) .collect(); const accounts = await ctx.db .query("accounts") .withIndex("by_user", (index) => index.eq("userId", userId)) .collect(); return { categories, accounts, categoryById: new Map(categories.map((category) => [category._id, category])), accountById: new Map(accounts.map((account) => [account._id, account])), }; } function findAccountIdByName( accounts: Doc<"accounts">[], accountName: string | undefined, ): Id<"accounts"> | undefined { const normalized = accountName?.trim().toLocaleLowerCase("de-DE"); if (!normalized) return undefined; return accounts.find((account) => account.name.toLocaleLowerCase("de-DE") === normalized)?._id; } 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, categoryIds: Set> | 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, userId: Id<"users">, args: TransactionToolArgs, ): Promise { 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, ) { 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(); 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, ) { 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, ) { const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined; return tx.description.trim() || tx.counterparty?.trim() || category?.name || "Unbekannt"; } function dateForTransaction(tx: Doc<"transactions">) { return tx.valueDate || tx.bookingDate || tx.effectiveMonth || "n/a"; } function monthIndexesAreConsecutive(months: string[]) { if (months.length < 2) return false; for (let index = 1; index < months.length; index++) { if (parseMonthIndex(months[index]) - parseMonthIndex(months[index - 1]) !== 1) { return false; } } return true; } function amountSeriesIsStable(amounts: number[]) { const average = amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length; const maxDeviation = Math.max(...amounts.map((amount) => Math.abs(amount - average))); return maxDeviation <= Math.max(Math.abs(average) * 0.2, 5); } type RecurringDetectionOptions = { beforeMonth?: string; expensesOnly?: boolean; fixedCategoriesOnly?: boolean; includeUncategorized?: boolean; }; function detectRecurringPatterns( context: ToolTransactionContext, options: RecurringDetectionOptions = {}, ) { const groups = new Map< string, { label: string; counterparty?: string; categoryName?: string; amountsByMonth: Map; 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(), 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 => 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) { return new Map(summary.categoryBreakdown.map((entry) => [entry.name, entry.amount])); } function toDisplayContextLine( tx: Doc<"transactions">, categoryById: Map, string>, accountById: Map, 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, 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(); for (const tx of uncategorized) { const name = tx.counterparty?.trim() || tx.description.trim() || "Unbekannt"; const entry = counterpartyMap.get(name) ?? { count: 0, amount: 0 }; entry.count += 1; entry.amount += tx.amount; counterpartyMap.set(name, entry); } return { from: context.from, to: context.to, basis: context.basis, accountName: context.accountName, totalCount: uncategorized.length, hasMore: uncategorized.length > limit, totals: calculateTotals(uncategorized), topCounterparties: [...counterpartyMap.entries()] .map(([name, entry]) => ({ name, count: entry.count, amount: roundMoney(entry.amount) })) .sort((a, b) => b.count - a.count || Math.abs(b.amount) - Math.abs(a.amount) || a.name.localeCompare(b.name, "de-DE")) .slice(0, 5), rows: uncategorized.slice(0, limit).map((tx) => safeTransactionRow(tx, context)), }; }, }); export const comparePeriodsTool = internalQuery({ args: { scope: toolScopeValidator, from: v.optional(v.string()), to: v.optional(v.string()), compareFrom: v.string(), compareTo: v.string(), accountId: v.optional(v.id("accounts")), accountName: v.optional(v.string()), categoryIds: v.optional(v.array(v.id("categories"))), categoryNames: v.optional(v.array(v.string())), search: v.optional(v.string()), type: v.optional(transactionTypeFilterValidator), }, returns: v.object({ basis: v.union(v.literal("effective"), v.literal("booking")), accountName: v.optional(v.string()), 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 } >(); 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() }; 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>(); const amountByRecurringLabelMonth = new Map>(); 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(); 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(); 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 => { 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 { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function maybeString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function maybeNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function summarizeToolInput(input: unknown) { const record = unknownRecord(input); const parts = []; const from = maybeString(record.from); const to = maybeString(record.to); const search = maybeString(record.search); const limit = maybeNumber(record.limit); const horizonMonths = maybeNumber(record.horizonMonths); const type = maybeString(record.type); const categoryNames = Array.isArray(record.categoryNames) ? record.categoryNames.filter((name): name is string => typeof name === "string") : []; if (from || to) parts.push(`${from ?? "?"} bis ${to ?? "?"}`); if (search) parts.push(`Suche "${search}"`); if (categoryNames.length > 0) parts.push(`Kategorien ${categoryNames.join(", ")}`); if (type) parts.push(type === "income" ? "Einnahmen" : "Ausgaben"); if (horizonMonths) parts.push(`${horizonMonths} Monate Prognose`); if (limit) parts.push(`Limit ${limit}`); return parts.length > 0 ? parts.join(", ") : "Standard-Kontext"; } function totalsFromOutput(output: Record) { 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) { 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}`; } return "Werkzeug ausgeführt"; } export function buildToolTraceFromSteps(steps: unknown[]): ToolTrace[] { const trace: ToolTrace[] = []; for (const step of steps) { const stepRecord = unknownRecord(step); const toolResults = Array.isArray(stepRecord.toolResults) ? stepRecord.toolResults : []; for (const result of toolResults) { const resultRecord = unknownRecord(result); const name = maybeString(resultRecord.toolName); if (!name) continue; trace.push({ name, inputSummary: summarizeToolInput(resultRecord.input), resultSummary: summarizeToolOutput(name, resultRecord.output), }); } } return trace; } const transactionToolInputSchema = z.object({ from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."), to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."), accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."), categoryNames: z .array(z.string()) .optional() .describe("Optionale Kategorienamen wie Lebensmittel oder Miete; 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."), }); export const ask = action({ args: { messages: v.array(chatMessageValidator), from: v.string(), to: v.string(), accountId: v.optional(v.id("accounts")), basis: v.union(v.literal("effective"), v.literal("booking")), }, returns: v.object({ model: v.string(), answer: v.string(), usedTransactions: v.number(), usedBalance: v.object({ income: v.number(), expenses: v.number(), balance: v.number(), }), toolTrace: v.array(toolTraceValidator), }), handler: async (ctx, args): Promise => { if (args.messages.length === 0) { throw new Error("Kein Nutzernachrichttext vorhanden."); } if (!process.env.OPENAI_API_KEY) { throw new Error( "OPENAI_API_KEY ist nicht gesetzt. Bitte API-Key in den Convex-Umgebungsvariablen hinterlegen.", ); } await requireUserId(ctx); const scope: AgentToolScope = { from: args.from, to: args.to, accountId: args.accountId, basis: args.basis, }; const selectedSummary: { totalCount: number; totals: { income: number; expenses: number; balance: number; transactionCount: number }; accountName?: string; } = await ctx.runQuery(internal.savingsChat.getTransactionsTool, { scope, limit: 1, }); const lastMessages = args.messages .map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content })) .slice(-MAX_CONVERSATION_MESSAGES); const system = buildSystemPrompt({ from: args.from, to: args.to, basis: args.basis, accountName: selectedSummary.accountName, }); const savingsTools = { get_transactions: tool({ description: "Ruft passende Umsätze read-only ab. Nutze dieses Tool für Detailfragen, Suche nach Gegenparteien/Beschreibungen oder Belege einzelner Aussagen. Es liefert exakte Summen und nur begrenzte, sanitizte Zeilen.", inputSchema: transactionToolInputSchema, execute: async (input) => await ctx.runQuery(internal.savingsChat.getTransactionsTool, { scope, ...input, }), }), summarize_spending: tool({ description: "Berechnet read-only exakte Summen, Monatsverläufe, Kategorien sowie fixe und variable Ausgaben für den ausgewählten oder angegebenen Zeitraum.", inputSchema: summaryToolInputSchema, execute: async (input) => await ctx.runQuery(internal.savingsChat.summarizeSpendingTool, { scope, ...input, }), }), forecast_cashflow: tool({ description: "Erstellt eine deterministische Cashflow-Prognose für 1 bis 3 kommende Monate aus vollständigen historischen Monaten. Nutze es für Sparrate, Monatsüberschuss und kurzfristige Prognosen.", inputSchema: forecastToolInputSchema, execute: async (input) => await ctx.runQuery(internal.savingsChat.forecastCashflowTool, { scope, ...input, }), }), get_accounts: tool({ description: "Listet read-only Konten mit Typ, Währung, Archivstatus, Startsaldo, Umsatzanzahl und Zeitraumssaldo. Nutze es für Fragen nach Konten, Konto-Scope oder Datenabdeckung.", inputSchema: accountToolInputSchema, execute: async (input) => await ctx.runQuery(internal.savingsChat.getAccountsTool, { scope, ...input, }), }), get_categories: tool({ description: "Listet read-only Kategorien mit Art, Fix/Variabel-Block, Umsatzanzahl, Summe und Ausgabenanteil im Zeitraum. Nutze es für Kategorie- und Budgetstrukturfragen.", inputSchema: summaryToolInputSchema, execute: async (input) => await ctx.runQuery(internal.savingsChat.getCategoriesTool, { scope, ...input, }), }), detect_recurring_transactions: tool({ description: "Erkennt deterministisch monatlich wiederkehrende Muster nach Beschreibung, Gegenpartei, Kategorie und stabiler Betragshöhe. Nutze es für Miete, Gehalt, Abos und regelmäßige Abbuchungen.", inputSchema: recurringToolInputSchema, execute: async (input) => await ctx.runQuery(internal.savingsChat.detectRecurringTransactionsTool, { scope, ...input, }), }), find_anomalies: tool({ description: "Findet read-only auffällige Betragsausreißer und fehlende erwartete wiederkehrende Buchungen gegenüber historischen Mustern.", inputSchema: anomalyToolInputSchema, execute: async (input) => await ctx.runQuery(internal.savingsChat.findAnomaliesTool, { scope, ...input, }), }), get_uncategorized_transactions: tool({ description: "Ruft bounded und sanitizt unklassifizierte Umsätze mit Summen und Top-Gegenparteien ab. Nutze es für Datenqualität und Fragen nach fehlenden Kategorien.", inputSchema: uncategorizedToolInputSchema, execute: async (input) => await ctx.runQuery(internal.savingsChat.getUncategorizedTransactionsTool, { scope, ...input, }), }), compare_periods: tool({ description: "Vergleicht zwei Zeiträume deterministisch mit Totals, Monatsverlauf, Kategorie-Deltas und Fix/Variabel-Deltas.", inputSchema: comparePeriodsToolInputSchema, execute: async (input) => await ctx.runQuery(internal.savingsChat.comparePeriodsTool, { scope, ...input, }), }), forecast_fixed_costs: tool({ description: "Prognostiziert wiederkehrende Fixkosten für 1 bis 6 Monate aus Fixkosten-Kategorien und stabilen historischen Monatsmustern.", inputSchema: fixedCostsForecastToolInputSchema, execute: async (input) => await ctx.runQuery(internal.savingsChat.forecastFixedCostsTool, { scope, ...input, }), }), explain_savings_rate: tool({ description: "Berechnet Sparquote, gesparten Betrag, fixe und variable Kostenquote, Haupttreiber und konkrete Hebel aus exakten Aggregaten.", inputSchema: summaryToolInputSchema, execute: async (input) => await ctx.runQuery(internal.savingsChat.explainSavingsRateTool, { scope, ...input, }), }), }; const envModel = process.env.SAVINGS_CHAT_MODEL?.trim(); const candidates = [ envModel, "gpt-5.4-mini", "gpt-4.1-mini", "gpt-4.1", ].filter(Boolean) as string[]; let lastError: unknown; for (const modelName of candidates) { try { const result = await generateText({ model: openai(modelName), system, messages: lastMessages, tools: savingsTools, stopWhen: stepCountIs(5), }); return { model: modelName, answer: result.text, usedTransactions: selectedSummary.totals.transactionCount, usedBalance: { income: selectedSummary.totals.income, expenses: selectedSummary.totals.expenses, balance: selectedSummary.totals.balance, }, toolTrace: buildToolTraceFromSteps(result.steps), }; } catch (error) { lastError = error; } } const message = lastError instanceof Error ? lastError.message : "Unbekannter Fehler bei der KI-Anfrage"; throw new Error(`KI-Anfrage fehlgeschlagen: ${message}`); }, });