Files
finanzen/convex/savingsChat.ts

949 lines
32 KiB
TypeScript
Raw Permalink 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, 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<Id<"categories">, Doc<"categories">>;
accountById: Map<Id<"accounts">, 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<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),
});
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<ChatContextSummary> => {
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<ToolTransactionContext, "categoryById" | "accountById">,
) {
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<ToolTransactionContext> {
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<ToolTransactionContext, "categoryById" | "accountById">,
) {
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<string, { income: number; expenses: number }>();
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<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 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<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),
),
};
},
});
function unknownRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
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<string, unknown>) {
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<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 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}`);
},
});