Improve category filter alias resolution

This commit is contained in:
2026-06-16 09:44:37 +02:00
parent 0061d5ad82
commit 3541d00864
3 changed files with 534 additions and 24 deletions

View File

@@ -666,6 +666,224 @@ describe("savingsChat read-only agent tools", () => {
]);
});
test("summarizeSpendingTool resolves supermarket category aliases", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Alias User",
email: "alias@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const groceryId = await ctx.db.insert("categories", {
userId,
name: "Lebensmittel & Supermarkt",
kind: "ausgabe",
block: "variabel",
color: "#ef4444",
sortOrder: 1,
isSystem: true,
});
const householdId = await ctx.db.insert("categories", {
userId,
name: "Haushalt & Discounter",
kind: "ausgabe",
block: "variabel",
color: "#fb923c",
sortOrder: 2,
isSystem: true,
});
for (const tx of [
{ date: "2026-01-10", description: "REWE", amount: -100, categoryId: groceryId },
{ date: "2026-02-10", description: "Kaufland", amount: -120, categoryId: groceryId },
{ date: "2026-02-12", description: "Drogerie", amount: -40, categoryId: householdId },
]) {
await ctx.db.insert("transactions", {
userId,
accountId,
categoryId: tx.categoryId,
bookingDate: tx.date,
valueDate: tx.date,
description: tx.description,
amount: tx.amount,
isPending: false,
effectiveMonth: tx.date.slice(0, 7),
});
}
return { userId, accountId };
});
const asUser = t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
});
for (const categoryName of ["Lebensmittel und Supermarkt", "Supermärkte", "Supermaerkte"]) {
const result = await asUser.query(internal.savingsChat.summarizeSpendingTool, {
scope: {
from: "2026-01-01",
to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
},
categoryNames: [categoryName],
});
expect(result.totals).toEqual({
transactionCount: 2,
income: 0,
expenses: -220,
balance: -220,
});
expect(result.categoryBreakdown.map((entry) => [entry.name, entry.amount])).toEqual([
["Lebensmittel & Supermarkt", -220],
]);
expect(result.categoryFilter?.diagnostics).toEqual([
{
requested: categoryName,
status: "resolved",
matchedName: "Lebensmittel & Supermarkt",
suggestions: [],
},
]);
}
});
test("getCategoriesTool reports unresolved category filters with suggestions", async () => {
const { asUser, seeded } = await seedSavingsInsightFixture();
const result = await asUser.query(internal.savingsChat.getCategoriesTool, {
scope: {
from: "2026-01-01",
to: "2026-03-31",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
},
categoryNames: ["Lebensmittel Urlaub"],
});
expect(result.categories).toEqual([]);
expect(result.categoryFilter?.diagnostics).toEqual([
{
requested: "Lebensmittel Urlaub",
status: "unresolved",
suggestions: ["Lebensmittel"],
},
]);
});
test("getCategoriesTool reports ambiguous category filters without applying them", async () => {
const t = convexTest(schema, modules);
const seeded = await t.run(async (ctx) => {
const userId = await ctx.db.insert("users", {
name: "Ambiguous User",
email: "ambiguous@example.com",
});
const accountId = await ctx.db.insert("accounts", {
userId,
name: "Girokonto",
type: "checking",
openingBalance: 0,
currency: "EUR",
isArchived: false,
});
const onlineShoppingId = await ctx.db.insert("categories", {
userId,
name: "Shopping & Online",
kind: "ausgabe",
block: "variabel",
color: "#9333ea",
sortOrder: 1,
isSystem: true,
});
const clothesShoppingId = await ctx.db.insert("categories", {
userId,
name: "Shopping & Kleidung",
kind: "ausgabe",
block: "variabel",
color: "#db2777",
sortOrder: 2,
isSystem: true,
});
for (const tx of [
{ date: "2026-02-01", description: "Online Shop", amount: -50, categoryId: onlineShoppingId },
{ date: "2026-02-02", description: "Schuhe", amount: -80, categoryId: clothesShoppingId },
]) {
await ctx.db.insert("transactions", {
userId,
accountId,
categoryId: tx.categoryId,
bookingDate: tx.date,
valueDate: tx.date,
description: tx.description,
amount: tx.amount,
isPending: false,
effectiveMonth: tx.date.slice(0, 7),
});
}
return { userId, accountId };
});
const result = await t.withIdentity({
subject: `${seeded.userId}|test-session`,
tokenIdentifier: `test:${seeded.userId}`,
}).query(internal.savingsChat.getCategoriesTool, {
scope: {
from: "2026-02-01",
to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
},
categoryNames: ["Shopping"],
});
expect(result.categories).toEqual([]);
expect(result.categoryFilter?.diagnostics).toEqual([
{
requested: "Shopping",
status: "ambiguous",
suggestions: ["Shopping & Kleidung", "Shopping & Online"],
},
]);
});
test("summarizeSpendingTool preserves the virtual Ohne Kategorie filter", async () => {
const { asUser, seeded } = await seedSavingsInsightFixture();
const result = await asUser.query(internal.savingsChat.summarizeSpendingTool, {
scope: {
from: "2026-02-01",
to: "2026-02-28",
accountId: seeded.accountId as Id<"accounts">,
basis: "effective",
},
categoryNames: ["Ohne Kategorie"],
});
expect(result.totals).toEqual({ transactionCount: 1, income: 0, expenses: -40, balance: -40 });
expect(result.categoryBreakdown).toEqual([{ name: "Ohne Kategorie", amount: -40 }]);
expect(result.categoryFilter?.diagnostics).toEqual([
{
requested: "Ohne Kategorie",
status: "resolved",
matchedName: "Ohne Kategorie",
suggestions: [],
},
]);
});
test("forecastCashflowTool excludes partial current month from the baseline", async () => {
const t = convexTest(schema, modules);
@@ -1181,4 +1399,39 @@ describe("savingsChat read-only agent tools", () => {
]);
expect(JSON.stringify(trace)).not.toMatch(/rawText|notes|dedupHash|externalRef|userId|_id|iban|externalId/);
});
test("buildToolTraceFromSteps surfaces category filter diagnostics", () => {
const trace = buildToolTraceFromSteps([
{
toolResults: [
{
toolName: "summarize_spending",
input: { categoryNames: ["Lebensmittel Urlaub"] },
output: {
totals: { income: 0, expenses: 0, balance: 0, transactionCount: 0 },
categoryBreakdown: [],
categoryFilter: {
diagnostics: [
{
requested: "Lebensmittel Urlaub",
status: "unresolved",
suggestions: ["Lebensmittel & Supermarkt"],
},
{
requested: "Shopping",
status: "ambiguous",
suggestions: ["Shopping & Kleidung", "Shopping & Online"],
},
],
},
},
},
],
},
]);
expect(trace[0].resultSummary).toBe(
"0 Umsätze, Saldo 0.00€, 0 Kategorien, Kategorie-Filter: Lebensmittel Urlaub unklar (Vorschlag: Lebensmittel & Supermarkt); Shopping mehrdeutig (Vorschläge: Shopping & Kleidung, Shopping & Online)",
);
});
});

View File

@@ -49,6 +49,20 @@ type ChatAskResult = {
};
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;
@@ -73,6 +87,7 @@ type ToolTransactionContext = {
categoryById: Map<Id<"categories">, Doc<"categories">>;
accountById: Map<Id<"accounts">, Doc<"accounts">>;
transactions: Doc<"transactions">[];
categoryFilter?: CategoryFilterInfo;
};
function formatEuro(value: number): string {
@@ -84,6 +99,8 @@ function buildSystemPrompt(context: { from: string; to: string; basis: string; a
"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}.`,
@@ -340,6 +357,17 @@ const categoryInsightValidator = v.object({
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()),
@@ -482,32 +510,154 @@ function findAccountIdByName(
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;
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 (args.categoryIds && args.categoryIds.length > 0) {
if (categoryIds) {
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;
}
if (!categoryId) return includeUncategorized;
if (!categoryIds.has(categoryId)) return false;
}
const search = args.search?.trim().toLocaleLowerCase("de-DE");
@@ -538,12 +688,27 @@ async function buildToolTransactionContext(
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));
})).filter((tx) =>
transactionMatchesToolFilters(
tx,
args,
maps,
categoryIdFilter,
resolvedCategoryFilter.includeUncategorized,
),
);
return {
...maps,
@@ -552,6 +717,7 @@ async function buildToolTransactionContext(
accountId,
accountName: account?.name,
transactions,
categoryFilter: resolvedCategoryFilter.categoryFilter,
};
}
@@ -803,6 +969,7 @@ export const getTransactionsTool = internalQuery({
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,
@@ -821,6 +988,7 @@ export const getTransactionsTool = internalQuery({
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),
@@ -846,6 +1014,7 @@ export const summarizeSpendingTool = internalQuery({
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(),
@@ -862,6 +1031,7 @@ export const summarizeSpendingTool = internalQuery({
to: context.to,
basis: context.basis,
accountName: context.accountName,
...categoryFilterResult(context.categoryFilter),
...summary,
};
},
@@ -1003,6 +1173,7 @@ export const getCategoriesTool = internalQuery({
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) => {
@@ -1064,6 +1235,7 @@ export const getCategoriesTool = internalQuery({
to: context.to,
basis: context.basis,
accountName: context.accountName,
...categoryFilterResult(context.categoryFilter),
categories,
};
},
@@ -1134,6 +1306,7 @@ export const comparePeriodsTool = internalQuery({
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,
@@ -1162,6 +1335,7 @@ export const comparePeriodsTool = internalQuery({
return {
basis: currentContext.basis,
accountName: currentContext.accountName,
...categoryFilterResult(currentContext.categoryFilter),
current: summarizeSnapshot(currentContext, currentSummary),
previous: summarizeSnapshot(previousContext, previousSummary),
deltas: {
@@ -1202,6 +1376,7 @@ export const explainSavingsRateTool = internalQuery({
returns: v.object({
from: v.string(),
to: v.string(),
categoryFilter: v.optional(categoryFilterValidator),
income: v.number(),
expenses: v.number(),
savedAmount: v.number(),
@@ -1239,6 +1414,7 @@ export const explainSavingsRateTool = internalQuery({
return {
from: context.from,
to: context.to,
...categoryFilterResult(context.categoryFilter),
income: summary.totals.income,
expenses: summary.totals.expenses,
savedAmount: summary.totals.balance,
@@ -1269,6 +1445,7 @@ export const detectRecurringTransactionsTool = internalQuery({
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) => {
@@ -1279,6 +1456,7 @@ export const detectRecurringTransactionsTool = internalQuery({
to: context.to,
basis: context.basis,
accountName: context.accountName,
...categoryFilterResult(context.categoryFilter),
patterns: detectRecurringPatterns(context),
};
},
@@ -1537,14 +1715,40 @@ 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") {
@@ -1552,7 +1756,7 @@ function summarizeToolOutput(toolName: string, output: unknown) {
const categoryCount = Array.isArray(record.categoryBreakdown)
? record.categoryBreakdown.length
: 0;
return `${totals.count} Umsätze, Saldo ${formatEuro(totals.balance)}, ${categoryCount} Kategorien`;
return `${totals.count} Umsätze, Saldo ${formatEuro(totals.balance)}, ${categoryCount} Kategorien${categoryFilterSummary}`;
}
if (toolName === "forecast_cashflow") {
@@ -1569,12 +1773,12 @@ function summarizeToolOutput(toolName: string, output: unknown) {
if (toolName === "get_categories") {
const count = Array.isArray(record.categories) ? record.categories.length : 0;
return `${countLabel(count, "Kategorie", "Kategorien")} ausgewertet`;
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`;
return `${count} ${count === 1 ? "wiederkehrendes Muster" : "wiederkehrende Muster"} erkannt${categoryFilterSummary}`;
}
if (toolName === "find_anomalies") {
@@ -1591,7 +1795,7 @@ function summarizeToolOutput(toolName: string, output: unknown) {
if (toolName === "compare_periods") {
const deltas = unknownRecord(record.deltas);
const balance = maybeNumber(deltas.balance) ?? 0;
return `Periodenvergleich, Saldo-Differenz ${formatEuro(balance)}`;
return `Periodenvergleich, Saldo-Differenz ${formatEuro(balance)}${categoryFilterSummary}`;
}
if (toolName === "forecast_fixed_costs") {
@@ -1602,7 +1806,7 @@ function summarizeToolOutput(toolName: string, output: unknown) {
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)}`;
return `Sparquote ${(savingsRate * 100).toFixed(1)}%, gespart ${formatEuro(savedAmount)}${categoryFilterSummary}`;
}
return "Werkzeug ausgeführt";
@@ -1631,7 +1835,10 @@ 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."),
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."),
@@ -1677,7 +1884,10 @@ const comparePeriodsToolInputSchema = z.object({
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."),
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."),
});