2190 lines
76 KiB
TypeScript
2190 lines
76 KiB
TypeScript
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 { ActionCtx, 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 CategoryFilterStatus = "resolved" | "unresolved" | "ambiguous";
|
||
type CategoryFilterDiagnostic = {
|
||
requested: string;
|
||
status: CategoryFilterStatus;
|
||
matchedName?: string;
|
||
suggestions: string[];
|
||
};
|
||
type CategoryFilterInfo = { diagnostics: CategoryFilterDiagnostic[] };
|
||
type CategoryFilterResolution = {
|
||
categoryIds: Id<"categories">[];
|
||
includeUncategorized: boolean;
|
||
categoryFilter?: CategoryFilterInfo;
|
||
hasNameFilter: boolean;
|
||
};
|
||
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">[];
|
||
categoryFilter?: CategoryFilterInfo;
|
||
};
|
||
|
||
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.",
|
||
"Nutze get_categories ohne Kategorie-Filter, wenn du vorhandene Kategorienamen prüfen musst.",
|
||
"Wenn ein Werkzeug categoryFilter-Diagnosen mit unresolved oder ambiguous liefert, nenne die Kategorie als nicht sicher gefunden und verwende keine gesicherte 0-Euro-Aussage.",
|
||
"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"))),
|
||
});
|
||
|
||
const safeCategoryBreakdownValidator = v.object({
|
||
name: v.string(),
|
||
amount: v.number(),
|
||
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
|
||
});
|
||
|
||
const accountInsightValidator = v.object({
|
||
name: v.string(),
|
||
type: v.string(),
|
||
currency: v.string(),
|
||
isArchived: v.boolean(),
|
||
openingBalance: v.number(),
|
||
transactionCount: v.number(),
|
||
balance: v.number(),
|
||
});
|
||
|
||
const categoryInsightValidator = v.object({
|
||
name: v.string(),
|
||
kind: v.union(v.literal("einnahme"), v.literal("ausgabe")),
|
||
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
|
||
isSystem: v.boolean(),
|
||
transactionCount: v.number(),
|
||
amount: v.number(),
|
||
shareOfExpenses: v.optional(v.number()),
|
||
});
|
||
|
||
const categoryFilterDiagnosticValidator = v.object({
|
||
requested: v.string(),
|
||
status: v.union(v.literal("resolved"), v.literal("unresolved"), v.literal("ambiguous")),
|
||
matchedName: v.optional(v.string()),
|
||
suggestions: v.array(v.string()),
|
||
});
|
||
|
||
const categoryFilterValidator = v.object({
|
||
diagnostics: v.array(categoryFilterDiagnosticValidator),
|
||
});
|
||
|
||
const recurringPatternValidator = v.object({
|
||
label: v.string(),
|
||
counterparty: v.optional(v.string()),
|
||
categoryName: v.optional(v.string()),
|
||
months: v.array(v.string()),
|
||
occurrenceCount: v.number(),
|
||
averageAmount: v.number(),
|
||
minAmount: v.number(),
|
||
maxAmount: v.number(),
|
||
frequency: v.literal("monthly"),
|
||
lastDate: v.string(),
|
||
nextExpectedMonth: v.string(),
|
||
});
|
||
|
||
const anomalyValidator = v.object({
|
||
kind: v.union(v.literal("amount_spike"), v.literal("missing_recurring")),
|
||
label: v.string(),
|
||
month: v.string(),
|
||
amount: v.number(),
|
||
expectedAmount: v.number(),
|
||
severity: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
|
||
});
|
||
|
||
const topCounterpartyValidator = v.object({
|
||
name: v.string(),
|
||
count: v.number(),
|
||
amount: v.number(),
|
||
});
|
||
|
||
const summarySnapshotValidator = v.object({
|
||
from: v.string(),
|
||
to: v.string(),
|
||
totals: totalsValidator,
|
||
fixedCosts: v.number(),
|
||
variableCosts: v.number(),
|
||
monthlyTrend: v.array(monthlyTrendValidator),
|
||
categoryBreakdown: v.array(safeCategoryBreakdownValidator),
|
||
});
|
||
|
||
const periodDeltasValidator = v.object({
|
||
income: v.number(),
|
||
expenses: v.number(),
|
||
balance: v.number(),
|
||
fixedCosts: v.number(),
|
||
variableCosts: v.number(),
|
||
});
|
||
|
||
const categoryDeltaValidator = v.object({
|
||
name: v.string(),
|
||
currentAmount: v.number(),
|
||
previousAmount: v.number(),
|
||
delta: v.number(),
|
||
});
|
||
|
||
const fixedCostItemValidator = v.object({
|
||
label: v.string(),
|
||
averageAmount: v.number(),
|
||
occurrenceCount: v.number(),
|
||
months: v.array(v.string()),
|
||
});
|
||
|
||
const fixedCostForecastMonthValidator = v.object({
|
||
month: v.string(),
|
||
totalFixedCosts: v.number(),
|
||
});
|
||
|
||
const savingsDriverValidator = v.object({
|
||
name: v.string(),
|
||
amount: v.number(),
|
||
shareOfExpenses: v.number(),
|
||
});
|
||
|
||
const savingsLeverValidator = v.object({
|
||
label: v.string(),
|
||
monthlyImpact: v.number(),
|
||
});
|
||
|
||
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;
|
||
}
|
||
|
||
const CATEGORY_TOKEN_STOPWORDS = new Set(["und"]);
|
||
|
||
function normalizeCategoryText(value: string) {
|
||
return value
|
||
.trim()
|
||
.toLocaleLowerCase("de-DE")
|
||
.replace(/ß/g, "ss")
|
||
.normalize("NFD")
|
||
.replace(/[\u0300-\u036f]/g, "")
|
||
.replace(/ae/g, "a")
|
||
.replace(/oe/g, "o")
|
||
.replace(/ue/g, "u");
|
||
}
|
||
|
||
function stemCategoryToken(token: string) {
|
||
for (const ending of ["innen", "ern", "en", "er", "es", "e", "n", "s"]) {
|
||
if (token.length - ending.length >= 4 && token.endsWith(ending)) {
|
||
return token.slice(0, -ending.length);
|
||
}
|
||
}
|
||
return token;
|
||
}
|
||
|
||
function categoryTokens(value: string) {
|
||
return normalizeCategoryText(value)
|
||
.replace(/&/g, " und ")
|
||
.replace(/[^a-z0-9]+/g, " ")
|
||
.split(" ")
|
||
.map((token) => token.trim())
|
||
.filter((token) => token && !CATEGORY_TOKEN_STOPWORDS.has(token))
|
||
.map(stemCategoryToken);
|
||
}
|
||
|
||
function categoryTokenKey(tokens: string[]) {
|
||
return [...new Set(tokens)].sort((a, b) => a.localeCompare(b, "de-DE")).join(" ");
|
||
}
|
||
|
||
function categoryFilterResult(categoryFilter: CategoryFilterInfo | undefined) {
|
||
return categoryFilter ? { categoryFilter } : {};
|
||
}
|
||
|
||
function resolveCategoryNames(
|
||
categories: Doc<"categories">[],
|
||
categoryNames: string[] | undefined,
|
||
): CategoryFilterResolution {
|
||
const requestedNames = categoryNames?.map((name) => name.trim()).filter(Boolean) ?? [];
|
||
if (requestedNames.length === 0) {
|
||
return { categoryIds: [], includeUncategorized: false, hasNameFilter: false };
|
||
}
|
||
|
||
const indexedCategories = [
|
||
...categories.map((category) => ({
|
||
categoryId: category._id as Id<"categories"> | undefined,
|
||
name: category.name,
|
||
sortOrder: category.sortOrder,
|
||
})),
|
||
{ categoryId: undefined, name: "Ohne Kategorie", sortOrder: Number.MAX_SAFE_INTEGER },
|
||
].map((category) => {
|
||
const tokens = categoryTokens(category.name);
|
||
return { ...category, tokens, tokenSet: new Set(tokens), key: categoryTokenKey(tokens) };
|
||
});
|
||
const categoryIds: Id<"categories">[] = [];
|
||
const diagnostics: CategoryFilterDiagnostic[] = [];
|
||
let includeUncategorized = false;
|
||
|
||
for (const requested of requestedNames) {
|
||
const requestedTokens = categoryTokens(requested);
|
||
const requestedKey = categoryTokenKey(requestedTokens);
|
||
const exactMatches = requestedKey
|
||
? indexedCategories.filter((entry) => entry.key === requestedKey)
|
||
: [];
|
||
const containmentMatches =
|
||
exactMatches.length > 0
|
||
? exactMatches
|
||
: indexedCategories.filter(
|
||
(entry) =>
|
||
requestedTokens.length > 0 &&
|
||
requestedTokens.every((token) => entry.tokenSet.has(token)),
|
||
);
|
||
|
||
if (containmentMatches.length === 1) {
|
||
const matchedCategory = containmentMatches[0];
|
||
if (matchedCategory.categoryId) {
|
||
categoryIds.push(matchedCategory.categoryId);
|
||
} else {
|
||
includeUncategorized = true;
|
||
}
|
||
diagnostics.push({
|
||
requested,
|
||
status: "resolved",
|
||
matchedName: matchedCategory.name,
|
||
suggestions: [],
|
||
});
|
||
continue;
|
||
}
|
||
|
||
if (containmentMatches.length > 1) {
|
||
diagnostics.push({
|
||
requested,
|
||
status: "ambiguous",
|
||
suggestions: containmentMatches
|
||
.map((entry) => entry.name)
|
||
.sort((a, b) => a.localeCompare(b, "de-DE"))
|
||
.slice(0, 5),
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const suggestions = indexedCategories
|
||
.map((entry) => ({
|
||
name: entry.name,
|
||
sortOrder: entry.sortOrder,
|
||
score: requestedTokens.filter((token) => entry.tokenSet.has(token)).length,
|
||
}))
|
||
.filter((entry) => entry.score > 0)
|
||
.sort((a, b) => b.score - a.score || a.sortOrder - b.sortOrder || a.name.localeCompare(b.name, "de-DE"))
|
||
.map((entry) => entry.name)
|
||
.slice(0, 5);
|
||
|
||
diagnostics.push({
|
||
requested,
|
||
status: "unresolved",
|
||
suggestions,
|
||
});
|
||
}
|
||
|
||
return {
|
||
categoryIds: [...new Set(categoryIds)],
|
||
includeUncategorized,
|
||
categoryFilter: { diagnostics },
|
||
hasNameFilter: true,
|
||
};
|
||
}
|
||
|
||
function transactionMatchesToolFilters(
|
||
tx: Doc<"transactions">,
|
||
args: TransactionToolArgs,
|
||
context: Pick<ToolTransactionContext, "categoryById" | "accountById">,
|
||
categoryIds: Set<Id<"categories">> | null,
|
||
includeUncategorized: boolean,
|
||
) {
|
||
if (args.type === "income" && tx.amount <= 0) return false;
|
||
if (args.type === "expense" && tx.amount >= 0) return false;
|
||
|
||
if (categoryIds) {
|
||
const categoryId = tx.categoryId;
|
||
if (!categoryId) return includeUncategorized;
|
||
if (!categoryIds.has(categoryId)) 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 resolvedCategoryFilter = resolveCategoryNames(maps.categories, args.categoryNames);
|
||
const explicitCategoryIds = args.categoryIds ?? [];
|
||
const categoryIds = [...new Set([...explicitCategoryIds, ...resolvedCategoryFilter.categoryIds])];
|
||
const categoryIdFilter =
|
||
explicitCategoryIds.length > 0 || resolvedCategoryFilter.hasNameFilter
|
||
? new Set(categoryIds)
|
||
: null;
|
||
const transactions = (await loadMatchingTransactions(ctx, userId, {
|
||
from: range.from,
|
||
to: range.to,
|
||
accountId,
|
||
basis: args.scope.basis,
|
||
})).filter((tx) =>
|
||
transactionMatchesToolFilters(
|
||
tx,
|
||
args,
|
||
maps,
|
||
categoryIdFilter,
|
||
resolvedCategoryFilter.includeUncategorized,
|
||
),
|
||
);
|
||
|
||
return {
|
||
...maps,
|
||
...range,
|
||
basis: args.scope.basis,
|
||
accountId,
|
||
accountName: account?.name,
|
||
transactions,
|
||
categoryFilter: resolvedCategoryFilter.categoryFilter,
|
||
};
|
||
}
|
||
|
||
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 roundRatio(value: number) {
|
||
return Math.round(value * 1000) / 1000;
|
||
}
|
||
|
||
function summarizeSnapshot(
|
||
context: ToolTransactionContext,
|
||
summary: ReturnType<typeof summarizeTransactions>,
|
||
) {
|
||
return {
|
||
from: context.from,
|
||
to: context.to,
|
||
totals: summary.totals,
|
||
fixedCosts: summary.fixedCosts,
|
||
variableCosts: summary.variableCosts,
|
||
monthlyTrend: summary.monthlyTrend,
|
||
categoryBreakdown: summary.categoryBreakdown.map((entry) => ({
|
||
name: entry.name,
|
||
amount: entry.amount,
|
||
...(entry.block ? { block: entry.block } : {}),
|
||
})),
|
||
};
|
||
}
|
||
|
||
function normalizedText(value: string | undefined) {
|
||
return value?.trim().toLocaleLowerCase("de-DE") ?? "";
|
||
}
|
||
|
||
function recurringLabelForTransaction(
|
||
tx: Doc<"transactions">,
|
||
context: Pick<ToolTransactionContext, "categoryById">,
|
||
) {
|
||
const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined;
|
||
return tx.description.trim() || tx.counterparty?.trim() || category?.name || "Unbekannt";
|
||
}
|
||
|
||
function dateForTransaction(tx: Doc<"transactions">) {
|
||
return tx.valueDate || tx.bookingDate || tx.effectiveMonth || "n/a";
|
||
}
|
||
|
||
function monthIndexesAreConsecutive(months: string[]) {
|
||
if (months.length < 2) return false;
|
||
for (let index = 1; index < months.length; index++) {
|
||
if (parseMonthIndex(months[index]) - parseMonthIndex(months[index - 1]) !== 1) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function amountSeriesIsStable(amounts: number[]) {
|
||
const average = amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length;
|
||
const maxDeviation = Math.max(...amounts.map((amount) => Math.abs(amount - average)));
|
||
return maxDeviation <= Math.max(Math.abs(average) * 0.2, 5);
|
||
}
|
||
|
||
type RecurringDetectionOptions = {
|
||
beforeMonth?: string;
|
||
expensesOnly?: boolean;
|
||
fixedCategoriesOnly?: boolean;
|
||
includeUncategorized?: boolean;
|
||
};
|
||
|
||
function detectRecurringPatterns(
|
||
context: ToolTransactionContext,
|
||
options: RecurringDetectionOptions = {},
|
||
) {
|
||
const groups = new Map<
|
||
string,
|
||
{
|
||
label: string;
|
||
counterparty?: string;
|
||
categoryName?: string;
|
||
amountsByMonth: Map<string, number>;
|
||
lastDate: string;
|
||
firstCreationTime: number;
|
||
}
|
||
>();
|
||
|
||
for (const tx of context.transactions) {
|
||
if (tx.amount === 0) continue;
|
||
if (options.expensesOnly && tx.amount >= 0) continue;
|
||
if (!options.includeUncategorized && !tx.categoryId) continue;
|
||
|
||
const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined;
|
||
if (options.fixedCategoriesOnly && category?.block !== "wiederkehrend") continue;
|
||
|
||
const month = monthKeyFromBasis(tx, context.basis);
|
||
if (!month || (options.beforeMonth && month >= options.beforeMonth)) continue;
|
||
|
||
const label = recurringLabelForTransaction(tx, context);
|
||
const key = [
|
||
normalizedText(label),
|
||
normalizedText(tx.counterparty),
|
||
tx.categoryId ?? "none",
|
||
tx.amount > 0 ? "income" : "expense",
|
||
].join("|");
|
||
const entry = groups.get(key) ?? {
|
||
label,
|
||
counterparty: tx.counterparty,
|
||
categoryName: category?.name,
|
||
amountsByMonth: new Map<string, number>(),
|
||
lastDate: dateForTransaction(tx),
|
||
firstCreationTime: tx._creationTime,
|
||
};
|
||
entry.amountsByMonth.set(month, (entry.amountsByMonth.get(month) ?? 0) + tx.amount);
|
||
if (dateForTransaction(tx) > entry.lastDate) entry.lastDate = dateForTransaction(tx);
|
||
entry.firstCreationTime = Math.min(entry.firstCreationTime, tx._creationTime);
|
||
groups.set(key, entry);
|
||
}
|
||
|
||
return [...groups.values()]
|
||
.map((entry) => {
|
||
const monthAmounts = [...entry.amountsByMonth.entries()].sort(([a], [b]) =>
|
||
a.localeCompare(b),
|
||
);
|
||
const months = monthAmounts.map(([month]) => month);
|
||
const amounts = monthAmounts.map(([, amount]) => amount);
|
||
if (months.length < 2 || !monthIndexesAreConsecutive(months) || !amountSeriesIsStable(amounts)) {
|
||
return null;
|
||
}
|
||
|
||
const averageAmount = roundMoney(amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length);
|
||
return {
|
||
label: entry.label,
|
||
counterparty: entry.counterparty,
|
||
categoryName: entry.categoryName,
|
||
months,
|
||
occurrenceCount: months.length,
|
||
averageAmount,
|
||
minAmount: roundMoney(Math.min(...amounts)),
|
||
maxAmount: roundMoney(Math.max(...amounts)),
|
||
frequency: "monthly" as const,
|
||
lastDate: entry.lastDate,
|
||
nextExpectedMonth: addMonthsToMonthKey(months[months.length - 1], 1),
|
||
};
|
||
})
|
||
.filter((pattern): pattern is NonNullable<typeof pattern> => pattern !== null)
|
||
.sort(
|
||
(a, b) =>
|
||
b.occurrenceCount - a.occurrenceCount ||
|
||
a.label.localeCompare(b.label, "de-DE") ||
|
||
a.lastDate.localeCompare(b.lastDate),
|
||
);
|
||
}
|
||
|
||
function buildCategoryAmountMap(summary: ReturnType<typeof summarizeTransactions>) {
|
||
return new Map(summary.categoryBreakdown.map((entry) => [entry.name, 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()),
|
||
categoryFilter: v.optional(categoryFilterValidator),
|
||
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,
|
||
...categoryFilterResult(context.categoryFilter),
|
||
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()),
|
||
categoryFilter: v.optional(categoryFilterValidator),
|
||
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,
|
||
...categoryFilterResult(context.categoryFilter),
|
||
...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 getAccountsTool = 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()),
|
||
includeArchived: v.optional(v.boolean()),
|
||
},
|
||
returns: v.object({
|
||
from: v.string(),
|
||
to: v.string(),
|
||
basis: v.union(v.literal("effective"), v.literal("booking")),
|
||
accounts: v.array(accountInsightValidator),
|
||
}),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
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 transactions = await loadMatchingTransactions(ctx, userId, {
|
||
...range,
|
||
accountId,
|
||
basis: args.scope.basis,
|
||
});
|
||
const transactionsByAccount = new Map<Id<"accounts">, Doc<"transactions">[]>();
|
||
for (const tx of transactions) {
|
||
if (!tx.accountId) continue;
|
||
const list = transactionsByAccount.get(tx.accountId) ?? [];
|
||
list.push(tx);
|
||
transactionsByAccount.set(tx.accountId, list);
|
||
}
|
||
|
||
const accounts = maps.accounts
|
||
.filter((account) => {
|
||
if (accountId && account._id !== accountId) return false;
|
||
return args.includeArchived === true || !account.isArchived;
|
||
})
|
||
.sort((a, b) => Number(a.isArchived) - Number(b.isArchived) || a.name.localeCompare(b.name, "de-DE"))
|
||
.map((account) => {
|
||
const accountTransactions = transactionsByAccount.get(account._id) ?? [];
|
||
return {
|
||
name: account.name,
|
||
type: account.type,
|
||
currency: account.currency,
|
||
isArchived: account.isArchived,
|
||
openingBalance: roundMoney(account.openingBalance),
|
||
transactionCount: accountTransactions.length,
|
||
balance: calculateTotals(accountTransactions).balance,
|
||
};
|
||
});
|
||
|
||
return {
|
||
from: range.from,
|
||
to: range.to,
|
||
basis: args.scope.basis,
|
||
accounts,
|
||
};
|
||
},
|
||
});
|
||
|
||
export const getCategoriesTool = 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()),
|
||
categoryFilter: v.optional(categoryFilterValidator),
|
||
categories: v.array(categoryInsightValidator),
|
||
}),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
const context = await buildToolTransactionContext(ctx, userId, args);
|
||
const totalExpenses = Math.abs(calculateTotals(context.transactions).expenses);
|
||
const sortOrderByCategory = new Map(context.categories.map((category) => [category._id, category.sortOrder]));
|
||
const categoryRows = new Map<
|
||
string,
|
||
{
|
||
categoryId?: Id<"categories">;
|
||
name: string;
|
||
kind: "einnahme" | "ausgabe";
|
||
block?: "wiederkehrend" | "variabel";
|
||
isSystem: boolean;
|
||
transactionCount: number;
|
||
amount: number;
|
||
}
|
||
>();
|
||
|
||
for (const tx of context.transactions) {
|
||
const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined;
|
||
const key = tx.categoryId ?? "none";
|
||
const existing = categoryRows.get(key) ?? {
|
||
categoryId: tx.categoryId,
|
||
name: category?.name ?? "Ohne Kategorie",
|
||
kind: category?.kind ?? (tx.amount >= 0 ? "einnahme" : "ausgabe"),
|
||
block: category?.block,
|
||
isSystem: category?.isSystem ?? false,
|
||
transactionCount: 0,
|
||
amount: 0,
|
||
};
|
||
existing.transactionCount += 1;
|
||
existing.amount += tx.amount;
|
||
if (!category && existing.amount < 0) existing.kind = "ausgabe";
|
||
categoryRows.set(key, existing);
|
||
}
|
||
|
||
const categories = [...categoryRows.values()]
|
||
.sort((a, b) => {
|
||
const aSort = a.categoryId ? sortOrderByCategory.get(a.categoryId) ?? 0 : Number.MAX_SAFE_INTEGER;
|
||
const bSort = b.categoryId ? sortOrderByCategory.get(b.categoryId) ?? 0 : Number.MAX_SAFE_INTEGER;
|
||
return aSort - bSort || a.name.localeCompare(b.name, "de-DE");
|
||
})
|
||
.map((entry) => ({
|
||
name: entry.name,
|
||
kind: entry.kind,
|
||
...(entry.block ? { block: entry.block } : {}),
|
||
isSystem: entry.isSystem,
|
||
transactionCount: entry.transactionCount,
|
||
amount: roundMoney(entry.amount),
|
||
...(entry.amount < 0 && totalExpenses > 0
|
||
? { shareOfExpenses: roundRatio(Math.abs(entry.amount) / totalExpenses) }
|
||
: {}),
|
||
}));
|
||
|
||
return {
|
||
from: context.from,
|
||
to: context.to,
|
||
basis: context.basis,
|
||
accountName: context.accountName,
|
||
...categoryFilterResult(context.categoryFilter),
|
||
categories,
|
||
};
|
||
},
|
||
});
|
||
|
||
export const getUncategorizedTransactionsTool = 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,
|
||
topCounterparties: v.array(topCounterpartyValidator),
|
||
rows: v.array(safeTransactionRowValidator),
|
||
}),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
const context = await buildToolTransactionContext(ctx, userId, {
|
||
...args,
|
||
categoryIds: undefined,
|
||
categoryNames: undefined,
|
||
});
|
||
const limit = clampToolLimit(args.limit);
|
||
const uncategorized = context.transactions.filter((tx) => !tx.categoryId);
|
||
const counterpartyMap = new Map<string, { count: number; amount: number }>();
|
||
for (const tx of uncategorized) {
|
||
const name = tx.counterparty?.trim() || tx.description.trim() || "Unbekannt";
|
||
const entry = counterpartyMap.get(name) ?? { count: 0, amount: 0 };
|
||
entry.count += 1;
|
||
entry.amount += tx.amount;
|
||
counterpartyMap.set(name, entry);
|
||
}
|
||
|
||
return {
|
||
from: context.from,
|
||
to: context.to,
|
||
basis: context.basis,
|
||
accountName: context.accountName,
|
||
totalCount: uncategorized.length,
|
||
hasMore: uncategorized.length > limit,
|
||
totals: calculateTotals(uncategorized),
|
||
topCounterparties: [...counterpartyMap.entries()]
|
||
.map(([name, entry]) => ({ name, count: entry.count, amount: roundMoney(entry.amount) }))
|
||
.sort((a, b) => b.count - a.count || Math.abs(b.amount) - Math.abs(a.amount) || a.name.localeCompare(b.name, "de-DE"))
|
||
.slice(0, 5),
|
||
rows: uncategorized.slice(0, limit).map((tx) => safeTransactionRow(tx, context)),
|
||
};
|
||
},
|
||
});
|
||
|
||
export const comparePeriodsTool = internalQuery({
|
||
args: {
|
||
scope: toolScopeValidator,
|
||
from: v.optional(v.string()),
|
||
to: v.optional(v.string()),
|
||
compareFrom: v.string(),
|
||
compareTo: 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({
|
||
basis: v.union(v.literal("effective"), v.literal("booking")),
|
||
accountName: v.optional(v.string()),
|
||
categoryFilter: v.optional(categoryFilterValidator),
|
||
current: summarySnapshotValidator,
|
||
previous: summarySnapshotValidator,
|
||
deltas: periodDeltasValidator,
|
||
categoryDeltas: v.array(categoryDeltaValidator),
|
||
}),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
const currentContext = await buildToolTransactionContext(ctx, userId, args);
|
||
const previousContext = await buildToolTransactionContext(ctx, userId, {
|
||
scope: args.scope,
|
||
from: args.compareFrom,
|
||
to: args.compareTo,
|
||
accountId: args.accountId,
|
||
accountName: args.accountName,
|
||
categoryIds: args.categoryIds,
|
||
categoryNames: args.categoryNames,
|
||
search: args.search,
|
||
type: args.type,
|
||
});
|
||
const currentSummary = summarizeTransactions(currentContext);
|
||
const previousSummary = summarizeTransactions(previousContext);
|
||
const currentCategoryMap = buildCategoryAmountMap(currentSummary);
|
||
const previousCategoryMap = buildCategoryAmountMap(previousSummary);
|
||
const categoryNames = new Set([...currentCategoryMap.keys(), ...previousCategoryMap.keys()]);
|
||
|
||
return {
|
||
basis: currentContext.basis,
|
||
accountName: currentContext.accountName,
|
||
...categoryFilterResult(currentContext.categoryFilter),
|
||
current: summarizeSnapshot(currentContext, currentSummary),
|
||
previous: summarizeSnapshot(previousContext, previousSummary),
|
||
deltas: {
|
||
income: roundMoney(currentSummary.totals.income - previousSummary.totals.income),
|
||
expenses: roundMoney(currentSummary.totals.expenses - previousSummary.totals.expenses),
|
||
balance: roundMoney(currentSummary.totals.balance - previousSummary.totals.balance),
|
||
fixedCosts: roundMoney(currentSummary.fixedCosts - previousSummary.fixedCosts),
|
||
variableCosts: roundMoney(currentSummary.variableCosts - previousSummary.variableCosts),
|
||
},
|
||
categoryDeltas: [...categoryNames]
|
||
.map((name) => {
|
||
const currentAmount = currentCategoryMap.get(name) ?? 0;
|
||
const previousAmount = previousCategoryMap.get(name) ?? 0;
|
||
return {
|
||
name,
|
||
currentAmount,
|
||
previousAmount,
|
||
delta: roundMoney(currentAmount - previousAmount),
|
||
};
|
||
})
|
||
.sort((a, b) => a.delta - b.delta || a.name.localeCompare(b.name, "de-DE")),
|
||
};
|
||
},
|
||
});
|
||
|
||
export const explainSavingsRateTool = 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(),
|
||
categoryFilter: v.optional(categoryFilterValidator),
|
||
income: v.number(),
|
||
expenses: v.number(),
|
||
savedAmount: v.number(),
|
||
savingsRate: v.union(v.number(), v.null()),
|
||
fixedCosts: v.number(),
|
||
variableCosts: v.number(),
|
||
transactionCount: v.number(),
|
||
drivers: v.array(savingsDriverValidator),
|
||
levers: v.array(savingsLeverValidator),
|
||
}),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
const context = await buildToolTransactionContext(ctx, userId, args);
|
||
const summary = summarizeTransactions(context);
|
||
const totalExpenses = Math.abs(summary.totals.expenses);
|
||
const drivers = summary.categoryBreakdown.slice(0, 3).map((entry) => ({
|
||
name: entry.name,
|
||
amount: entry.amount,
|
||
shareOfExpenses: totalExpenses > 0 ? roundRatio(Math.abs(entry.amount) / totalExpenses) : 0,
|
||
}));
|
||
const levers = [];
|
||
if (summary.variableCosts < 0) {
|
||
levers.push({
|
||
label: "Variable Ausgaben um 10% senken",
|
||
monthlyImpact: roundMoney(Math.abs(summary.variableCosts) * 0.1),
|
||
});
|
||
}
|
||
if (summary.fixedCosts < 0) {
|
||
levers.push({
|
||
label: "Fixkosten um 5% senken",
|
||
monthlyImpact: roundMoney(Math.abs(summary.fixedCosts) * 0.05),
|
||
});
|
||
}
|
||
|
||
return {
|
||
from: context.from,
|
||
to: context.to,
|
||
...categoryFilterResult(context.categoryFilter),
|
||
income: summary.totals.income,
|
||
expenses: summary.totals.expenses,
|
||
savedAmount: summary.totals.balance,
|
||
savingsRate: summary.totals.income > 0 ? roundRatio(summary.totals.balance / summary.totals.income) : null,
|
||
fixedCosts: summary.fixedCosts,
|
||
variableCosts: summary.variableCosts,
|
||
transactionCount: summary.totals.transactionCount,
|
||
drivers,
|
||
levers,
|
||
};
|
||
},
|
||
});
|
||
|
||
export const detectRecurringTransactionsTool = 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()),
|
||
categoryFilter: v.optional(categoryFilterValidator),
|
||
patterns: v.array(recurringPatternValidator),
|
||
}),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
const context = await buildToolTransactionContext(ctx, userId, args);
|
||
return {
|
||
from: context.from,
|
||
to: context.to,
|
||
basis: context.basis,
|
||
accountName: context.accountName,
|
||
...categoryFilterResult(context.categoryFilter),
|
||
patterns: detectRecurringPatterns(context),
|
||
};
|
||
},
|
||
});
|
||
|
||
export const forecastFixedCostsTool = 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()),
|
||
items: v.array(fixedCostItemValidator),
|
||
forecast: v.array(fixedCostForecastMonthValidator),
|
||
}),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
const context = await buildToolTransactionContext(ctx, userId, args);
|
||
const fixedGroups = new Map<
|
||
string,
|
||
{ label: string; amountsByMonth: Map<string, number> }
|
||
>();
|
||
|
||
for (const tx of context.transactions) {
|
||
if (tx.amount >= 0 || !tx.categoryId) continue;
|
||
const category = context.categoryById.get(tx.categoryId);
|
||
if (category?.block !== "wiederkehrend") continue;
|
||
const month = monthKeyFromBasis(tx, context.basis);
|
||
if (!month) continue;
|
||
const label = recurringLabelForTransaction(tx, context);
|
||
const key = [normalizedText(label), tx.categoryId, normalizedText(tx.counterparty)].join("|");
|
||
const entry = fixedGroups.get(key) ?? { label, amountsByMonth: new Map<string, number>() };
|
||
entry.amountsByMonth.set(month, (entry.amountsByMonth.get(month) ?? 0) + tx.amount);
|
||
fixedGroups.set(key, entry);
|
||
}
|
||
|
||
const items = [...fixedGroups.values()]
|
||
.map((entry) => {
|
||
const monthAmounts = [...entry.amountsByMonth.entries()].sort(([a], [b]) =>
|
||
a.localeCompare(b),
|
||
);
|
||
const months = monthAmounts.map(([month]) => month);
|
||
const amounts = monthAmounts.map(([, amount]) => amount);
|
||
return {
|
||
label: entry.label,
|
||
averageAmount: roundMoney(amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length),
|
||
occurrenceCount: months.length,
|
||
months,
|
||
};
|
||
})
|
||
.filter((item) => item.months.length > 0)
|
||
.sort((a, b) => a.averageAmount - b.averageAmount || a.label.localeCompare(b.label, "de-DE"));
|
||
const horizonMonths = Math.max(1, Math.min(6, Math.floor(args.horizonMonths ?? 3)));
|
||
const asOfMonth = (args.asOf ?? context.to).slice(0, 7);
|
||
const totalFixedCosts = roundMoney(items.reduce((sum, item) => sum + item.averageAmount, 0));
|
||
|
||
return {
|
||
from: context.from,
|
||
to: context.to,
|
||
basis: context.basis,
|
||
accountName: context.accountName,
|
||
items,
|
||
forecast: Array.from({ length: horizonMonths }, (_, index) => ({
|
||
month: addMonthsToMonthKey(asOfMonth, index + 1),
|
||
totalFixedCosts,
|
||
})),
|
||
};
|
||
},
|
||
});
|
||
|
||
export const findAnomaliesTool = 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()),
|
||
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()),
|
||
anomalies: v.array(anomalyValidator),
|
||
}),
|
||
handler: async (ctx, args) => {
|
||
const userId = await requireUserId(ctx);
|
||
const context = await buildToolTransactionContext(ctx, userId, args);
|
||
const targetMonth = (args.asOf ?? context.to).slice(0, 7);
|
||
const amountByCategoryMonth = new Map<string, Map<string, number>>();
|
||
const amountByRecurringLabelMonth = new Map<string, Map<string, number>>();
|
||
|
||
for (const tx of context.transactions) {
|
||
const month = monthKeyFromBasis(tx, context.basis);
|
||
if (!month) continue;
|
||
|
||
const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined;
|
||
const categoryName = category?.name ?? "Ohne Kategorie";
|
||
const categoryMonthMap = amountByCategoryMonth.get(categoryName) ?? new Map<string, number>();
|
||
categoryMonthMap.set(month, (categoryMonthMap.get(month) ?? 0) + tx.amount);
|
||
amountByCategoryMonth.set(categoryName, categoryMonthMap);
|
||
|
||
if (tx.categoryId) {
|
||
const label = recurringLabelForTransaction(tx, context);
|
||
const recurringMonthMap = amountByRecurringLabelMonth.get(label) ?? new Map<string, number>();
|
||
recurringMonthMap.set(month, (recurringMonthMap.get(month) ?? 0) + tx.amount);
|
||
amountByRecurringLabelMonth.set(label, recurringMonthMap);
|
||
}
|
||
}
|
||
|
||
const anomalies: Array<{
|
||
kind: "amount_spike" | "missing_recurring";
|
||
label: string;
|
||
month: string;
|
||
amount: number;
|
||
expectedAmount: number;
|
||
severity: "low" | "medium" | "high";
|
||
}> = [];
|
||
|
||
for (const [label, amountsByMonth] of amountByCategoryMonth.entries()) {
|
||
const currentAmount = roundMoney(amountsByMonth.get(targetMonth) ?? 0);
|
||
if (currentAmount >= 0) continue;
|
||
const baseline = [...amountsByMonth.entries()]
|
||
.filter(([month, amount]) => month < targetMonth && amount < 0)
|
||
.map(([, amount]) => amount);
|
||
if (baseline.length < 2) continue;
|
||
const expectedAmount = roundMoney(baseline.reduce((sum, amount) => sum + amount, 0) / baseline.length);
|
||
const ratio = Math.abs(currentAmount) / Math.max(Math.abs(expectedAmount), 1);
|
||
if (ratio >= 2 && Math.abs(currentAmount - expectedAmount) >= 100) {
|
||
anomalies.push({
|
||
kind: "amount_spike",
|
||
label,
|
||
month: targetMonth,
|
||
amount: currentAmount,
|
||
expectedAmount,
|
||
severity: ratio >= 3 ? "high" : "medium",
|
||
});
|
||
}
|
||
}
|
||
|
||
for (const pattern of detectRecurringPatterns(context, { beforeMonth: targetMonth })) {
|
||
if (pattern.nextExpectedMonth !== targetMonth) continue;
|
||
const actualAmount = roundMoney(amountByRecurringLabelMonth.get(pattern.label)?.get(targetMonth) ?? 0);
|
||
if (actualAmount === 0) {
|
||
anomalies.push({
|
||
kind: "missing_recurring",
|
||
label: pattern.label,
|
||
month: targetMonth,
|
||
amount: 0,
|
||
expectedAmount: pattern.averageAmount,
|
||
severity: "medium",
|
||
});
|
||
}
|
||
}
|
||
|
||
return {
|
||
from: context.from,
|
||
to: context.to,
|
||
basis: context.basis,
|
||
accountName: context.accountName,
|
||
anomalies: anomalies.sort(
|
||
(a, b) =>
|
||
(b.severity === "high" ? 2 : b.severity === "medium" ? 1 : 0) -
|
||
(a.severity === "high" ? 2 : a.severity === "medium" ? 1 : 0) ||
|
||
a.label.localeCompare(b.label, "de-DE"),
|
||
),
|
||
};
|
||
},
|
||
});
|
||
|
||
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 countLabel(count: number, singular: string, plural: string) {
|
||
return `${count} ${count === 1 ? singular : plural}`;
|
||
}
|
||
|
||
function summarizeCategoryFilterDiagnostics(output: Record<string, unknown>) {
|
||
const categoryFilter = unknownRecord(output.categoryFilter);
|
||
const diagnostics = Array.isArray(categoryFilter.diagnostics)
|
||
? categoryFilter.diagnostics.map(unknownRecord)
|
||
: [];
|
||
const issues = diagnostics
|
||
.map((diagnostic) => {
|
||
const status = maybeString(diagnostic.status);
|
||
if (status !== "unresolved" && status !== "ambiguous") return null;
|
||
|
||
const requested = maybeString(diagnostic.requested) ?? "unbekannt";
|
||
const suggestions = Array.isArray(diagnostic.suggestions)
|
||
? diagnostic.suggestions.filter((suggestion): suggestion is string => typeof suggestion === "string")
|
||
: [];
|
||
const statusLabel = status === "ambiguous" ? "mehrdeutig" : "unklar";
|
||
if (suggestions.length === 0) return `${requested} ${statusLabel}`;
|
||
|
||
const suggestionLabel = suggestions.length === 1 ? "Vorschlag" : "Vorschläge";
|
||
return `${requested} ${statusLabel} (${suggestionLabel}: ${suggestions.join(", ")})`;
|
||
})
|
||
.filter((issue): issue is string => issue !== null);
|
||
|
||
return issues.length > 0 ? `, Kategorie-Filter: ${issues.join("; ")}` : "";
|
||
}
|
||
|
||
function summarizeToolOutput(toolName: string, output: unknown) {
|
||
const record = unknownRecord(output);
|
||
const categoryFilterSummary = summarizeCategoryFilterDiagnostics(record);
|
||
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"
|
||
}${categoryFilterSummary}`;
|
||
}
|
||
|
||
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${categoryFilterSummary}`;
|
||
}
|
||
|
||
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)}`;
|
||
}
|
||
|
||
if (toolName === "get_accounts") {
|
||
const count = Array.isArray(record.accounts) ? record.accounts.length : 0;
|
||
return `${countLabel(count, "Konto", "Konten")} ausgewertet`;
|
||
}
|
||
|
||
if (toolName === "get_categories") {
|
||
const count = Array.isArray(record.categories) ? record.categories.length : 0;
|
||
return `${countLabel(count, "Kategorie", "Kategorien")} ausgewertet${categoryFilterSummary}`;
|
||
}
|
||
|
||
if (toolName === "detect_recurring_transactions") {
|
||
const count = Array.isArray(record.patterns) ? record.patterns.length : 0;
|
||
return `${count} ${count === 1 ? "wiederkehrendes Muster" : "wiederkehrende Muster"} erkannt${categoryFilterSummary}`;
|
||
}
|
||
|
||
if (toolName === "find_anomalies") {
|
||
const count = Array.isArray(record.anomalies) ? record.anomalies.length : 0;
|
||
return `${count} ${count === 1 ? "Auffälligkeit" : "Auffälligkeiten"} erkannt`;
|
||
}
|
||
|
||
if (toolName === "get_uncategorized_transactions") {
|
||
const count = maybeNumber(record.totalCount) ?? 0;
|
||
const hasMore = record.hasMore === true;
|
||
return `${count} unklassifizierte Umsätze, ${hasMore ? "weitere vorhanden" : "vollständig"}`;
|
||
}
|
||
|
||
if (toolName === "compare_periods") {
|
||
const deltas = unknownRecord(record.deltas);
|
||
const balance = maybeNumber(deltas.balance) ?? 0;
|
||
return `Periodenvergleich, Saldo-Differenz ${formatEuro(balance)}${categoryFilterSummary}`;
|
||
}
|
||
|
||
if (toolName === "forecast_fixed_costs") {
|
||
const count = Array.isArray(record.forecast) ? record.forecast.length : 0;
|
||
return `Fixkosten-Prognose ${count} Monate`;
|
||
}
|
||
|
||
if (toolName === "explain_savings_rate") {
|
||
const savingsRate = maybeNumber(record.savingsRate) ?? 0;
|
||
const savedAmount = maybeNumber(record.savedAmount) ?? 0;
|
||
return `Sparquote ${(savingsRate * 100).toFixed(1)}%, gespart ${formatEuro(savedAmount)}${categoryFilterSummary}`;
|
||
}
|
||
|
||
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; Namen werden tolerant gegen &/und, Pluralformen, Umlaute und Groß-/Kleinschreibung aufgelöst."),
|
||
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."),
|
||
});
|
||
|
||
const accountToolInputSchema = 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 nur ein Konto betrachtet werden soll."),
|
||
includeArchived: z.boolean().optional().describe("Archivierte Konten einbeziehen."),
|
||
});
|
||
|
||
const recurringToolInputSchema = summaryToolInputSchema;
|
||
|
||
const anomalyToolInputSchema = 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."),
|
||
asOf: z.string().optional().describe("Stichtag für erwartete Muster im Format YYYY-MM-DD."),
|
||
});
|
||
|
||
const uncategorizedToolInputSchema = 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."),
|
||
search: z.string().optional().describe("Optionaler Suchtext für Beschreibung oder Gegenpartei."),
|
||
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 comparePeriodsToolInputSchema = z.object({
|
||
from: z.string().optional().describe("Startdatum des aktuellen Zeitraums im Format YYYY-MM-DD."),
|
||
to: z.string().optional().describe("Enddatum des aktuellen Zeitraums im Format YYYY-MM-DD."),
|
||
compareFrom: z.string().describe("Startdatum des Vergleichszeitraums im Format YYYY-MM-DD."),
|
||
compareTo: z.string().describe("Enddatum des Vergleichszeitraums 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 für den Vergleich; Namen werden tolerant gegen &/und, Pluralformen, Umlaute und Groß-/Kleinschreibung aufgelöst."),
|
||
search: z.string().optional().describe("Optionaler Suchtext für beide Zeiträume."),
|
||
type: z.enum(["income", "expense"]).optional().describe("Optional nur Einnahmen oder Ausgaben vergleichen."),
|
||
});
|
||
|
||
const fixedCostsForecastToolInputSchema = z.object({
|
||
from: z.string().optional().describe("Optionales Startdatum der historischen Fixkostenbasis im Format YYYY-MM-DD."),
|
||
to: z.string().optional().describe("Optionales Enddatum der historischen Fixkostenbasis 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(6).optional().describe("Anzahl der zu prognostizierenden Monate, 1 bis 6."),
|
||
asOf: z.string().optional().describe("Stichtag für den Start der Prognose im Format YYYY-MM-DD."),
|
||
});
|
||
|
||
async function generateSavingsChatResponse(
|
||
ctx: ActionCtx,
|
||
args: ChatContextArgs & { messages: ChatMessage[] },
|
||
): 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,
|
||
}),
|
||
}),
|
||
get_accounts: tool({
|
||
description:
|
||
"Listet read-only Konten mit Typ, Währung, Archivstatus, Startsaldo, Umsatzanzahl und Zeitraumssaldo. Nutze es für Fragen nach Konten, Konto-Scope oder Datenabdeckung.",
|
||
inputSchema: accountToolInputSchema,
|
||
execute: async (input) =>
|
||
await ctx.runQuery(internal.savingsChat.getAccountsTool, {
|
||
scope,
|
||
...input,
|
||
}),
|
||
}),
|
||
get_categories: tool({
|
||
description:
|
||
"Listet read-only Kategorien mit Art, Fix/Variabel-Block, Umsatzanzahl, Summe und Ausgabenanteil im Zeitraum. Nutze es für Kategorie- und Budgetstrukturfragen.",
|
||
inputSchema: summaryToolInputSchema,
|
||
execute: async (input) =>
|
||
await ctx.runQuery(internal.savingsChat.getCategoriesTool, {
|
||
scope,
|
||
...input,
|
||
}),
|
||
}),
|
||
detect_recurring_transactions: tool({
|
||
description:
|
||
"Erkennt deterministisch monatlich wiederkehrende Muster nach Beschreibung, Gegenpartei, Kategorie und stabiler Betragshöhe. Nutze es für Miete, Gehalt, Abos und regelmäßige Abbuchungen.",
|
||
inputSchema: recurringToolInputSchema,
|
||
execute: async (input) =>
|
||
await ctx.runQuery(internal.savingsChat.detectRecurringTransactionsTool, {
|
||
scope,
|
||
...input,
|
||
}),
|
||
}),
|
||
find_anomalies: tool({
|
||
description:
|
||
"Findet read-only auffällige Betragsausreißer und fehlende erwartete wiederkehrende Buchungen gegenüber historischen Mustern.",
|
||
inputSchema: anomalyToolInputSchema,
|
||
execute: async (input) =>
|
||
await ctx.runQuery(internal.savingsChat.findAnomaliesTool, {
|
||
scope,
|
||
...input,
|
||
}),
|
||
}),
|
||
get_uncategorized_transactions: tool({
|
||
description:
|
||
"Ruft bounded und sanitizt unklassifizierte Umsätze mit Summen und Top-Gegenparteien ab. Nutze es für Datenqualität und Fragen nach fehlenden Kategorien.",
|
||
inputSchema: uncategorizedToolInputSchema,
|
||
execute: async (input) =>
|
||
await ctx.runQuery(internal.savingsChat.getUncategorizedTransactionsTool, {
|
||
scope,
|
||
...input,
|
||
}),
|
||
}),
|
||
compare_periods: tool({
|
||
description:
|
||
"Vergleicht zwei Zeiträume deterministisch mit Totals, Monatsverlauf, Kategorie-Deltas und Fix/Variabel-Deltas.",
|
||
inputSchema: comparePeriodsToolInputSchema,
|
||
execute: async (input) =>
|
||
await ctx.runQuery(internal.savingsChat.comparePeriodsTool, {
|
||
scope,
|
||
...input,
|
||
}),
|
||
}),
|
||
forecast_fixed_costs: tool({
|
||
description:
|
||
"Prognostiziert wiederkehrende Fixkosten für 1 bis 6 Monate aus Fixkosten-Kategorien und stabilen historischen Monatsmustern.",
|
||
inputSchema: fixedCostsForecastToolInputSchema,
|
||
execute: async (input) =>
|
||
await ctx.runQuery(internal.savingsChat.forecastFixedCostsTool, {
|
||
scope,
|
||
...input,
|
||
}),
|
||
}),
|
||
explain_savings_rate: tool({
|
||
description:
|
||
"Berechnet Sparquote, gesparten Betrag, fixe und variable Kostenquote, Haupttreiber und konkrete Hebel aus exakten Aggregaten.",
|
||
inputSchema: summaryToolInputSchema,
|
||
execute: async (input) =>
|
||
await ctx.runQuery(internal.savingsChat.explainSavingsRateTool, {
|
||
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}`);
|
||
}
|
||
|
||
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> => {
|
||
return await generateSavingsChatResponse(ctx, {
|
||
...args,
|
||
messages: args.messages.map((message) => ({
|
||
role: normalizeRole(message.role),
|
||
content: message.content,
|
||
})),
|
||
});
|
||
},
|
||
});
|
||
|
||
export const sendMessage = action({
|
||
args: {
|
||
sessionId: v.id("chatSessions"),
|
||
content: v.string(),
|
||
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> => {
|
||
const content = args.content.trim();
|
||
if (!content) {
|
||
throw new Error("Kein Nutzernachrichttext vorhanden.");
|
||
}
|
||
|
||
await ctx.runMutation(internal.savingsChatHistory.appendUserMessage, {
|
||
sessionId: args.sessionId,
|
||
content,
|
||
});
|
||
const messages: ChatMessage[] = await ctx.runQuery(
|
||
internal.savingsChatHistory.getRecentMessagesForPrompt,
|
||
{
|
||
sessionId: args.sessionId,
|
||
limit: MAX_CONVERSATION_MESSAGES,
|
||
},
|
||
);
|
||
let response: ChatAskResult;
|
||
try {
|
||
response = await generateSavingsChatResponse(ctx, {
|
||
from: args.from,
|
||
to: args.to,
|
||
accountId: args.accountId,
|
||
basis: args.basis,
|
||
messages,
|
||
});
|
||
} catch (error) {
|
||
await ctx.runMutation(internal.savingsChatHistory.appendAssistantMessage, {
|
||
sessionId: args.sessionId,
|
||
content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.",
|
||
});
|
||
throw error;
|
||
}
|
||
await ctx.runMutation(internal.savingsChatHistory.appendAssistantMessage, {
|
||
sessionId: args.sessionId,
|
||
content: response.answer,
|
||
toolTrace: response.toolTrace,
|
||
});
|
||
return response;
|
||
},
|
||
});
|