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