import { action, internalQuery, query } from "./_generated/server"; import { v } from "convex/values"; import { generateText, stepCountIs, tool } from "ai"; import { openai } from "@ai-sdk/openai"; import { internal } from "./_generated/api"; import { z } from "zod"; import { addMonthsToMonthKey, bookingMonth, monthKeyFromBasis } from "./lib/month"; import { requireUserId } from "./lib/helpers"; import type { Doc, Id } from "./_generated/dataModel"; import type { QueryCtx } from "./_generated/server"; type ChatRole = "user" | "assistant"; type ChatMessage = { role: ChatRole; content: string }; const chatMessageValidator = v.object({ role: v.union(v.literal("user"), v.literal("assistant")), content: v.string(), }); const MAX_CONVERSATION_MESSAGES = 20; const DEFAULT_TOOL_ROW_LIMIT = 50; const MAX_TOOL_ROW_LIMIT = 200; const MAX_TOOL_RANGE_MONTHS = 18; type ChatContextArgs = { from: string; to: string; accountId?: Id<"accounts">; basis: "effective" | "booking"; }; type ChatContextSummary = { from: string; to: string; basis: "effective" | "booking"; accountId?: Id<"accounts">; accountName?: string; totals: { transactionCount: number; income: number; expenses: number; balance: number }; isComplete: true; }; type ChatPromptContext = ChatContextSummary & { transactionLines: string[]; }; type ChatAskResult = { model: string; answer: string; usedTransactions: number; usedBalance: { income: number; expenses: number; balance: number }; toolTrace: ToolTrace[]; }; type ToolTrace = { name: string; inputSummary: string; resultSummary: string }; type TransactionTypeFilter = "income" | "expense"; type AgentToolScope = ChatContextArgs; type TransactionToolArgs = { scope: AgentToolScope; from?: string; to?: string; accountId?: Id<"accounts">; accountName?: string; categoryIds?: Id<"categories">[]; categoryNames?: string[]; search?: string; type?: TransactionTypeFilter; limit?: number; }; type ToolTransactionContext = { from: string; to: string; basis: AgentToolScope["basis"]; accountId?: Id<"accounts">; accountName?: string; categories: Doc<"categories">[]; accounts: Doc<"accounts">[]; categoryById: Map, Doc<"categories">>; accountById: Map, Doc<"accounts">>; transactions: Doc<"transactions">[]; }; function formatEuro(value: number): string { return `${value.toFixed(2)}€`; } function buildSystemPrompt(context: { from: string; to: string; basis: string; accountName?: string }) { return [ "Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.", "Nutze ausschließlich die bereitgestellten Werkzeuge und deren Ergebnisse als Finanzkontext.", "Rufe Werkzeuge auf, wenn du Umsätze, Zusammenfassungen oder Prognosen brauchst; erfinde keine Beträge.", "Antworte auf Deutsch, kurz und handlungsorientiert.", `Zeitraum: ${context.from} bis ${context.to}.`, `Basis: ${context.basis}.`, context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.", "Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.", "Nenne keine internen IDs und keine Rohdatenfelder.", "Verwende keine Links, keine HTML-Tags und keine Emojis.", ].join(" "); } function normalizeRole(role: ChatRole): "user" | "assistant" { return role; } function sortTransactionsForContext( transactions: Doc<"transactions">[], basis: ChatContextArgs["basis"], ) { return transactions.sort((a, b) => { const aMonth = basis === "effective" ? a.effectiveMonth ?? bookingMonth(a.bookingDate) ?? "" : ""; const bMonth = basis === "effective" ? b.effectiveMonth ?? bookingMonth(b.bookingDate) ?? "" : ""; const aDate = basis === "booking" ? a.bookingDate ?? "" : a.valueDate ?? a.bookingDate ?? ""; const bDate = basis === "booking" ? b.bookingDate ?? "" : b.valueDate ?? b.bookingDate ?? ""; const aKey = `${aMonth}|${aDate}|${a._creationTime}`; const bKey = `${bMonth}|${bDate}|${b._creationTime}`; return bKey.localeCompare(aKey); }); } async function loadMatchingTransactions( ctx: QueryCtx, userId: Id<"users">, args: ChatContextArgs, ): Promise[]> { 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"))), }); 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; } function categoryNameSet(categoryNames: string[] | undefined) { const normalized = categoryNames ?.map((name) => name.trim().toLocaleLowerCase("de-DE")) .filter(Boolean); return normalized && normalized.length > 0 ? new Set(normalized) : null; } function transactionMatchesToolFilters( tx: Doc<"transactions">, args: TransactionToolArgs, context: Pick, ) { if (args.type === "income" && tx.amount <= 0) return false; if (args.type === "expense" && tx.amount >= 0) return false; if (args.categoryIds && args.categoryIds.length > 0) { const categoryId = tx.categoryId; if (!categoryId || !args.categoryIds.includes(categoryId)) return false; } const categoriesByName = categoryNameSet(args.categoryNames); if (categoriesByName) { const name = tx.categoryId ? context.categoryById.get(tx.categoryId)?.name : "Ohne Kategorie"; if (!categoriesByName.has((name ?? "Ohne Kategorie").toLocaleLowerCase("de-DE"))) { return false; } } const search = args.search?.trim().toLocaleLowerCase("de-DE"); if (search) { const categoryName = tx.categoryId ? context.categoryById.get(tx.categoryId)?.name : ""; const accountName = tx.accountId ? context.accountById.get(tx.accountId)?.name : ""; const haystack = [ tx.description, tx.counterparty, categoryName, accountName, ] .filter(Boolean) .join(" ") .toLocaleLowerCase("de-DE"); if (!haystack.includes(search)) return false; } return true; } async function buildToolTransactionContext( ctx: QueryCtx, userId: Id<"users">, args: TransactionToolArgs, ): Promise { const maps = await loadNameMaps(ctx, userId); const range = normalizeToolRange(args.scope, args.from, args.to); const accountId = args.accountId ?? findAccountIdByName(maps.accounts, args.accountName) ?? args.scope.accountId; const account = accountId ? maps.accountById.get(accountId) : undefined; const transactions = (await loadMatchingTransactions(ctx, userId, { from: range.from, to: range.to, accountId, basis: args.scope.basis, })).filter((tx) => transactionMatchesToolFilters(tx, args, maps)); return { ...maps, ...range, basis: args.scope.basis, accountId, accountName: account?.name, transactions, }; } function safeTransactionRow( tx: Doc<"transactions">, context: Pick, ) { 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 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()), totalCount: v.number(), hasMore: v.boolean(), totals: totalsValidator, rows: v.array(safeTransactionRowValidator), }), handler: async (ctx, args) => { const userId = await requireUserId(ctx); const context = await buildToolTransactionContext(ctx, userId, args); const limit = clampToolLimit(args.limit); const rows = context.transactions .slice(0, limit) .map((tx) => safeTransactionRow(tx, context)); return { from: context.from, to: context.to, basis: context.basis, accountName: context.accountName, totalCount: context.transactions.length, hasMore: context.transactions.length > limit, totals: calculateTotals(context.transactions), rows, }; }, }); export const summarizeSpendingTool = internalQuery({ args: { scope: toolScopeValidator, from: v.optional(v.string()), to: v.optional(v.string()), accountId: v.optional(v.id("accounts")), accountName: v.optional(v.string()), categoryIds: v.optional(v.array(v.id("categories"))), categoryNames: v.optional(v.array(v.string())), search: v.optional(v.string()), type: v.optional(transactionTypeFilterValidator), }, returns: v.object({ from: v.string(), to: v.string(), basis: v.union(v.literal("effective"), v.literal("booking")), accountName: v.optional(v.string()), totals: totalsValidator, fixedCosts: v.number(), variableCosts: v.number(), monthlyTrend: v.array(monthlyTrendValidator), categoryBreakdown: v.array(categoryBreakdownValidator), }), handler: async (ctx, args) => { const userId = await requireUserId(ctx); const context = await buildToolTransactionContext(ctx, userId, args); const summary = summarizeTransactions(context); return { from: context.from, to: context.to, basis: context.basis, accountName: context.accountName, ...summary, }; }, }); export const forecastCashflowTool = internalQuery({ args: { scope: toolScopeValidator, from: v.optional(v.string()), to: v.optional(v.string()), accountId: v.optional(v.id("accounts")), accountName: v.optional(v.string()), horizonMonths: v.optional(v.number()), asOf: v.optional(v.string()), }, returns: v.object({ from: v.string(), to: v.string(), basis: v.union(v.literal("effective"), v.literal("booking")), accountName: v.optional(v.string()), baselineMonths: v.array(v.string()), excludedPartialMonth: v.union(v.string(), v.null()), monthlyAverage: v.object({ income: v.number(), expenses: v.number(), balance: v.number(), }), projection: v.array(monthlyTrendValidator), }), handler: async (ctx, args) => { const userId = await requireUserId(ctx); const context = await buildToolTransactionContext(ctx, userId, args); const summary = summarizeTransactions(context); const asOfMonth = (args.asOf ?? new Date().toISOString().slice(0, 10)).slice(0, 7); const baseline = summary.monthlyTrend.filter((month) => month.month < asOfMonth); const excludedPartialMonth = summary.monthlyTrend.some((month) => month.month === asOfMonth) ? asOfMonth : null; const horizonMonths = Math.max(1, Math.min(3, Math.floor(args.horizonMonths ?? 3))); const denominator = baseline.length || 1; const monthlyAverage = { income: roundMoney(baseline.reduce((sum, month) => sum + month.income, 0) / denominator), expenses: roundMoney(baseline.reduce((sum, month) => sum + month.expenses, 0) / denominator), balance: roundMoney(baseline.reduce((sum, month) => sum + month.balance, 0) / denominator), }; const projection = Array.from({ length: horizonMonths }, (_, index) => ({ month: addMonthsToMonthKey(asOfMonth, index + 1), ...monthlyAverage, })); return { from: context.from, to: context.to, basis: context.basis, accountName: context.accountName, baselineMonths: baseline.map((month) => month.month), excludedPartialMonth, monthlyAverage, projection, }; }, }); export const 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 summarizeToolOutput(toolName: string, output: unknown) { const record = unknownRecord(output); if (toolName === "get_transactions") { const totals = totalsFromOutput(record); const hasMore = record.hasMore === true; return `${totals.count} Umsätze, Saldo ${formatEuro(totals.balance)}, ${ hasMore ? "weitere vorhanden" : "vollständig" }`; } if (toolName === "summarize_spending") { const totals = totalsFromOutput(record); const categoryCount = Array.isArray(record.categoryBreakdown) ? record.categoryBreakdown.length : 0; return `${totals.count} Umsätze, Saldo ${formatEuro(totals.balance)}, ${categoryCount} Kategorien`; } if (toolName === "forecast_cashflow") { const projectionCount = Array.isArray(record.projection) ? record.projection.length : 0; const average = unknownRecord(record.monthlyAverage); const balance = maybeNumber(average.balance) ?? 0; return `Prognose ${projectionCount} Monate, durchschnittlicher Saldo ${formatEuro(balance)}`; } return "Werkzeug ausgeführt"; } export function buildToolTraceFromSteps(steps: unknown[]): ToolTrace[] { const trace: ToolTrace[] = []; for (const step of steps) { const stepRecord = unknownRecord(step); const toolResults = Array.isArray(stepRecord.toolResults) ? stepRecord.toolResults : []; for (const result of toolResults) { const resultRecord = unknownRecord(result); const name = maybeString(resultRecord.toolName); if (!name) continue; trace.push({ name, inputSummary: summarizeToolInput(resultRecord.input), resultSummary: summarizeToolOutput(name, resultRecord.output), }); } } return trace; } const transactionToolInputSchema = z.object({ from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."), to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."), accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."), categoryNames: z.array(z.string()).optional().describe("Optionale Kategorienamen wie Lebensmittel oder Miete."), search: z.string().optional().describe("Optionaler Suchtext für Beschreibung, Gegenpartei, Konto oder Kategorie."), type: z.enum(["income", "expense"]).optional().describe("Optional nur Einnahmen oder Ausgaben abrufen."), limit: z.number().int().min(1).max(MAX_TOOL_ROW_LIMIT).optional().describe("Maximale Anzahl Umsatzzeilen."), }); const summaryToolInputSchema = transactionToolInputSchema.omit({ limit: true }); const forecastToolInputSchema = z.object({ from: z.string().optional().describe("Optionales Startdatum der historischen Basis im Format YYYY-MM-DD."), to: z.string().optional().describe("Optionales Enddatum der historischen Basis im Format YYYY-MM-DD."), accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."), horizonMonths: z.number().int().min(1).max(3).optional().describe("Anzahl der zu prognostizierenden Monate, 1 bis 3."), }); 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, }), }), }; 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}`); }, });