Add savings chat analysis feature
This commit is contained in:
399
convex/savingsChat.ts
Normal file
399
convex/savingsChat.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
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}`);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user