Files
finanzen/convex/savingsChat.ts
2026-06-15 18:26:25 +02:00

400 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { action, internalQuery, query } from "./_generated/server";
import { v } from "convex/values";
import { generateText } 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<Doc<"transactions">[]> {
const monthFrom = args.from.slice(0, 7);
const monthTo = args.to.slice(0, 7);
const transactions: Doc<"transactions">[] = [];
if (args.basis === "effective") {
if (args.accountId) {
const accountId = args.accountId;
const q = ctx.db
.query("transactions")
.withIndex("by_user_account_effmonth", (index) =>
index
.eq("userId", userId)
.eq("accountId", accountId)
.gte("effectiveMonth", monthFrom)
.lte("effectiveMonth", monthTo),
)
.order("desc");
for await (const tx of q) transactions.push(tx);
const fallback = ctx.db
.query("transactions")
.withIndex("by_user_account_booking", (index) =>
index
.eq("userId", userId)
.eq("accountId", accountId)
.gte("bookingDate", args.from)
.lte("bookingDate", args.to),
)
.order("desc");
for await (const tx of fallback) {
if (tx.effectiveMonth === undefined) transactions.push(tx);
}
return sortTransactionsForContext(transactions, args.basis);
}
const q = ctx.db
.query("transactions")
.withIndex("by_user_effmonth", (index) =>
index.eq("userId", userId).gte("effectiveMonth", monthFrom).lte("effectiveMonth", monthTo),
)
.order("desc");
for await (const tx of q) transactions.push(tx);
const fallback = ctx.db
.query("transactions")
.withIndex("by_user_booking", (index) =>
index.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to),
)
.order("desc");
for await (const tx of fallback) {
if (tx.effectiveMonth === undefined) transactions.push(tx);
}
return sortTransactionsForContext(transactions, args.basis);
}
if (args.accountId) {
const accountId = args.accountId;
const q = ctx.db
.query("transactions")
.withIndex("by_user_account_booking", (index) =>
index
.eq("userId", userId)
.eq("accountId", accountId)
.gte("bookingDate", args.from)
.lte("bookingDate", args.to),
)
.order("desc");
for await (const tx of q) transactions.push(tx);
return transactions;
}
const q = ctx.db
.query("transactions")
.withIndex("by_user_booking", (index) =>
index.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to),
)
.order("desc");
for await (const tx of q) transactions.push(tx);
return sortTransactionsForContext(transactions, args.basis);
}
function calculateTotals(transactions: Doc<"transactions">[]) {
const totals = transactions.reduce(
(acc, tx) => {
if (tx.amount > 0) acc.income += tx.amount;
if (tx.amount < 0) acc.expenses += tx.amount;
acc.balance += tx.amount;
acc.transactionCount += 1;
return acc;
},
{ income: 0, expenses: 0, balance: 0, transactionCount: 0 },
);
return {
transactionCount: totals.transactionCount,
income: Math.round(totals.income * 100) / 100,
expenses: Math.round(totals.expenses * 100) / 100,
balance: Math.round(totals.balance * 100) / 100,
};
}
async function buildContextSummary(
ctx: QueryCtx,
userId: Id<"users">,
args: ChatContextArgs,
): Promise<{ summary: ChatContextSummary; transactions: Doc<"transactions">[] }> {
const transactions = await loadMatchingTransactions(ctx, userId, args);
const account = args.accountId ? await ctx.db.get(args.accountId) : null;
return {
summary: {
from: args.from,
to: args.to,
basis: args.basis,
accountId: args.accountId,
accountName: account?.userId === userId ? account.name : undefined,
totals: calculateTotals(transactions),
isComplete: true,
},
transactions,
};
}
const contextArgsValidator = {
from: v.string(),
to: v.string(),
accountId: v.optional(v.id("accounts")),
basis: v.union(v.literal("effective"), v.literal("booking")),
};
const totalsValidator = v.object({
transactionCount: v.number(),
income: v.number(),
expenses: v.number(),
balance: v.number(),
});
const contextSummaryValidator = v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
totals: totalsValidator,
isComplete: v.literal(true),
});
export const getContext = query({
args: contextArgsValidator,
returns: contextSummaryValidator,
handler: async (ctx, args): Promise<ChatContextSummary> => {
const userId = await requireUserId(ctx);
const { summary } = await buildContextSummary(ctx, userId, args);
return summary;
},
});
function toDisplayContextLine(
tx: Doc<"transactions">,
categoryById: Map<Id<"categories">, string>,
accountById: Map<Id<"accounts">, string>,
) {
const date = tx.valueDate || tx.bookingDate || "n/a";
const amount = formatEuro(tx.amount);
const name = tx.counterparty ?? "";
const category = tx.categoryId ? categoryById.get(tx.categoryId) : "Ohne Kategorie";
const account = tx.accountId ? accountById.get(tx.accountId) : "Ohne Konto";
return `${date} | ${tx.description} (${name}) | ${amount} | ${category ?? "Ohne Kategorie"} | ${account ?? "Ohne Konto"}${
tx.isPending ? " | offen" : ""
}`;
}
export const getPromptContext = internalQuery({
args: contextArgsValidator,
returns: v.object({
...contextSummaryValidator.fields,
transactionLines: v.array(v.string()),
}),
handler: async (ctx, args): Promise<ChatPromptContext> => {
const userId = await requireUserId(ctx);
const { summary, transactions } = await buildContextSummary(ctx, userId, args);
const categories = await ctx.db
.query("categories")
.withIndex("by_user", (index) => index.eq("userId", userId))
.collect();
const accounts = await ctx.db
.query("accounts")
.withIndex("by_user", (index) => index.eq("userId", userId))
.collect();
const categoryById = new Map(categories.map((category) => [category._id, category.name]));
const accountById = new Map(accounts.map((account) => [account._id, account.name]));
return {
...summary,
transactionLines: transactions.map((tx) =>
toDisplayContextLine(tx, categoryById, accountById),
),
};
},
});
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<ChatAskResult> => {
if (args.messages.length === 0) {
throw new Error("Kein Nutzernachrichttext vorhanden.");
}
if (!process.env.OPENAI_API_KEY) {
throw new Error(
"OPENAI_API_KEY ist nicht gesetzt. Bitte API-Key in den Convex-Umgebungsvariablen hinterlegen.",
);
}
await requireUserId(ctx);
const 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}`);
},
});