400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
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}`);
|
||
},
|
||
});
|