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

@@ -0,0 +1,47 @@
---
id: TASK-8
title: Fix savings tool category matching
status: In Progress
assignee: []
created_date: '2026-06-15 20:10'
updated_date: '2026-06-15 20:19'
labels: []
dependencies: []
priority: high
ordinal: 8000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Make Talk to Savings read-only tools resolve user/model category names like Supermärkte or Lebensmittel und Supermarkt to existing categories such as Lebensmittel & Supermarkt, and surface diagnostics instead of misleading silent zero results.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Category filter aliases resolve ampersand/und, casing, punctuation, umlauts, and simple German plural/flexion variants
- [x] #2 All savings tools that accept categoryNames use shared resolution diagnostics
- [x] #3 Unknown or ambiguous category filters return diagnostics and compact trace summaries instead of silently confident zero results
- [x] #4 Regression tests cover Lebensmittel und Supermarkt, Supermärkte, unknown diagnostics, and trace summaries
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing Convex regression tests for tolerant categoryNames and diagnostics
2. Implement shared category-name normalization and resolver in convex/savingsChat.ts
3. Thread diagnostics through every categoryNames-aware tool output and compact traces
4. Update tool/prompt guidance to avoid confident zero answers on unresolved filters
5. Run focused Vitest, targeted ESLint, and production build
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented shared tolerant category resolver for savings tools and added diagnostics to categoryNames-aware outputs.
Verification passed: npx vitest convex/savingsChat.test.ts --run (20 tests), npx eslint convex/savingsChat.ts convex/savingsChat.test.ts, npm run build (with existing Vite chunk-size warning).
Added an additional regression test to preserve the virtual Ohne Kategorie filter while moving categoryNames to resolver-based category IDs. Re-ran focused Vitest, targeted ESLint, and build successfully after this adjustment.
Addressed QA review findings: added ASCII transliteration coverage for Supermaerkte, explicit ambiguous category filter coverage, and multi-diagnostic compact trace summaries. Final verification passed: npx vitest convex/savingsChat.test.ts --run (22 tests), npx eslint convex/savingsChat.ts convex/savingsChat.test.ts, npm run build (with existing Vite chunk-size warning).
<!-- SECTION:NOTES:END -->

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 () => { test("forecastCashflowTool excludes partial current month from the baseline", async () => {
const t = convexTest(schema, modules); 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/); 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 ToolTrace = { name: string; inputSummary: string; resultSummary: string };
type TransactionTypeFilter = "income" | "expense"; 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 AgentToolScope = ChatContextArgs;
type TransactionToolArgs = { type TransactionToolArgs = {
scope: AgentToolScope; scope: AgentToolScope;
@@ -73,6 +87,7 @@ type ToolTransactionContext = {
categoryById: Map<Id<"categories">, Doc<"categories">>; categoryById: Map<Id<"categories">, Doc<"categories">>;
accountById: Map<Id<"accounts">, Doc<"accounts">>; accountById: Map<Id<"accounts">, Doc<"accounts">>;
transactions: Doc<"transactions">[]; transactions: Doc<"transactions">[];
categoryFilter?: CategoryFilterInfo;
}; };
function formatEuro(value: number): string { 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.", "Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.",
"Nutze ausschließlich die bereitgestellten Werkzeuge und deren Ergebnisse als Finanzkontext.", "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.", "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.", "Antworte auf Deutsch, kurz und handlungsorientiert.",
`Zeitraum: ${context.from} bis ${context.to}.`, `Zeitraum: ${context.from} bis ${context.to}.`,
`Basis: ${context.basis}.`, `Basis: ${context.basis}.`,
@@ -340,6 +357,17 @@ const categoryInsightValidator = v.object({
shareOfExpenses: v.optional(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({ const recurringPatternValidator = v.object({
label: v.string(), label: v.string(),
counterparty: v.optional(v.string()), counterparty: v.optional(v.string()),
@@ -482,32 +510,154 @@ function findAccountIdByName(
return accounts.find((account) => account.name.toLocaleLowerCase("de-DE") === normalized)?._id; return accounts.find((account) => account.name.toLocaleLowerCase("de-DE") === normalized)?._id;
} }
function categoryNameSet(categoryNames: string[] | undefined) { const CATEGORY_TOKEN_STOPWORDS = new Set(["und"]);
const normalized = categoryNames
?.map((name) => name.trim().toLocaleLowerCase("de-DE")) function normalizeCategoryText(value: string) {
.filter(Boolean); return value
return normalized && normalized.length > 0 ? new Set(normalized) : null; .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( function transactionMatchesToolFilters(
tx: Doc<"transactions">, tx: Doc<"transactions">,
args: TransactionToolArgs, args: TransactionToolArgs,
context: Pick<ToolTransactionContext, "categoryById" | "accountById">, context: Pick<ToolTransactionContext, "categoryById" | "accountById">,
categoryIds: Set<Id<"categories">> | null,
includeUncategorized: boolean,
) { ) {
if (args.type === "income" && tx.amount <= 0) return false; if (args.type === "income" && tx.amount <= 0) return false;
if (args.type === "expense" && 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; const categoryId = tx.categoryId;
if (!categoryId || !args.categoryIds.includes(categoryId)) return false; if (!categoryId) return includeUncategorized;
} if (!categoryIds.has(categoryId)) return false;
const categoriesByName = categoryNameSet(args.categoryNames);
if (categoriesByName) {
const name = tx.categoryId ? context.categoryById.get(tx.categoryId)?.name : "Ohne Kategorie";
if (!categoriesByName.has((name ?? "Ohne Kategorie").toLocaleLowerCase("de-DE"))) {
return false;
}
} }
const search = args.search?.trim().toLocaleLowerCase("de-DE"); 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 range = normalizeToolRange(args.scope, args.from, args.to);
const accountId = args.accountId ?? findAccountIdByName(maps.accounts, args.accountName) ?? args.scope.accountId; const accountId = args.accountId ?? findAccountIdByName(maps.accounts, args.accountName) ?? args.scope.accountId;
const account = accountId ? maps.accountById.get(accountId) : undefined; 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, { const transactions = (await loadMatchingTransactions(ctx, userId, {
from: range.from, from: range.from,
to: range.to, to: range.to,
accountId, accountId,
basis: args.scope.basis, basis: args.scope.basis,
})).filter((tx) => transactionMatchesToolFilters(tx, args, maps)); })).filter((tx) =>
transactionMatchesToolFilters(
tx,
args,
maps,
categoryIdFilter,
resolvedCategoryFilter.includeUncategorized,
),
);
return { return {
...maps, ...maps,
@@ -552,6 +717,7 @@ async function buildToolTransactionContext(
accountId, accountId,
accountName: account?.name, accountName: account?.name,
transactions, transactions,
categoryFilter: resolvedCategoryFilter.categoryFilter,
}; };
} }
@@ -803,6 +969,7 @@ export const getTransactionsTool = internalQuery({
to: v.string(), to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")), basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()), accountName: v.optional(v.string()),
categoryFilter: v.optional(categoryFilterValidator),
totalCount: v.number(), totalCount: v.number(),
hasMore: v.boolean(), hasMore: v.boolean(),
totals: totalsValidator, totals: totalsValidator,
@@ -821,6 +988,7 @@ export const getTransactionsTool = internalQuery({
to: context.to, to: context.to,
basis: context.basis, basis: context.basis,
accountName: context.accountName, accountName: context.accountName,
...categoryFilterResult(context.categoryFilter),
totalCount: context.transactions.length, totalCount: context.transactions.length,
hasMore: context.transactions.length > limit, hasMore: context.transactions.length > limit,
totals: calculateTotals(context.transactions), totals: calculateTotals(context.transactions),
@@ -846,6 +1014,7 @@ export const summarizeSpendingTool = internalQuery({
to: v.string(), to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")), basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()), accountName: v.optional(v.string()),
categoryFilter: v.optional(categoryFilterValidator),
totals: totalsValidator, totals: totalsValidator,
fixedCosts: v.number(), fixedCosts: v.number(),
variableCosts: v.number(), variableCosts: v.number(),
@@ -862,6 +1031,7 @@ export const summarizeSpendingTool = internalQuery({
to: context.to, to: context.to,
basis: context.basis, basis: context.basis,
accountName: context.accountName, accountName: context.accountName,
...categoryFilterResult(context.categoryFilter),
...summary, ...summary,
}; };
}, },
@@ -1003,6 +1173,7 @@ export const getCategoriesTool = internalQuery({
to: v.string(), to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")), basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()), accountName: v.optional(v.string()),
categoryFilter: v.optional(categoryFilterValidator),
categories: v.array(categoryInsightValidator), categories: v.array(categoryInsightValidator),
}), }),
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -1064,6 +1235,7 @@ export const getCategoriesTool = internalQuery({
to: context.to, to: context.to,
basis: context.basis, basis: context.basis,
accountName: context.accountName, accountName: context.accountName,
...categoryFilterResult(context.categoryFilter),
categories, categories,
}; };
}, },
@@ -1134,6 +1306,7 @@ export const comparePeriodsTool = internalQuery({
returns: v.object({ returns: v.object({
basis: v.union(v.literal("effective"), v.literal("booking")), basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()), accountName: v.optional(v.string()),
categoryFilter: v.optional(categoryFilterValidator),
current: summarySnapshotValidator, current: summarySnapshotValidator,
previous: summarySnapshotValidator, previous: summarySnapshotValidator,
deltas: periodDeltasValidator, deltas: periodDeltasValidator,
@@ -1162,6 +1335,7 @@ export const comparePeriodsTool = internalQuery({
return { return {
basis: currentContext.basis, basis: currentContext.basis,
accountName: currentContext.accountName, accountName: currentContext.accountName,
...categoryFilterResult(currentContext.categoryFilter),
current: summarizeSnapshot(currentContext, currentSummary), current: summarizeSnapshot(currentContext, currentSummary),
previous: summarizeSnapshot(previousContext, previousSummary), previous: summarizeSnapshot(previousContext, previousSummary),
deltas: { deltas: {
@@ -1202,6 +1376,7 @@ export const explainSavingsRateTool = internalQuery({
returns: v.object({ returns: v.object({
from: v.string(), from: v.string(),
to: v.string(), to: v.string(),
categoryFilter: v.optional(categoryFilterValidator),
income: v.number(), income: v.number(),
expenses: v.number(), expenses: v.number(),
savedAmount: v.number(), savedAmount: v.number(),
@@ -1239,6 +1414,7 @@ export const explainSavingsRateTool = internalQuery({
return { return {
from: context.from, from: context.from,
to: context.to, to: context.to,
...categoryFilterResult(context.categoryFilter),
income: summary.totals.income, income: summary.totals.income,
expenses: summary.totals.expenses, expenses: summary.totals.expenses,
savedAmount: summary.totals.balance, savedAmount: summary.totals.balance,
@@ -1269,6 +1445,7 @@ export const detectRecurringTransactionsTool = internalQuery({
to: v.string(), to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")), basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()), accountName: v.optional(v.string()),
categoryFilter: v.optional(categoryFilterValidator),
patterns: v.array(recurringPatternValidator), patterns: v.array(recurringPatternValidator),
}), }),
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -1279,6 +1456,7 @@ export const detectRecurringTransactionsTool = internalQuery({
to: context.to, to: context.to,
basis: context.basis, basis: context.basis,
accountName: context.accountName, accountName: context.accountName,
...categoryFilterResult(context.categoryFilter),
patterns: detectRecurringPatterns(context), patterns: detectRecurringPatterns(context),
}; };
}, },
@@ -1537,14 +1715,40 @@ function countLabel(count: number, singular: string, plural: string) {
return `${count} ${count === 1 ? singular : plural}`; 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) { function summarizeToolOutput(toolName: string, output: unknown) {
const record = unknownRecord(output); const record = unknownRecord(output);
const categoryFilterSummary = summarizeCategoryFilterDiagnostics(record);
if (toolName === "get_transactions") { if (toolName === "get_transactions") {
const totals = totalsFromOutput(record); const totals = totalsFromOutput(record);
const hasMore = record.hasMore === true; const hasMore = record.hasMore === true;
return `${totals.count} Umsätze, Saldo ${formatEuro(totals.balance)}, ${ return `${totals.count} Umsätze, Saldo ${formatEuro(totals.balance)}, ${
hasMore ? "weitere vorhanden" : "vollständig" hasMore ? "weitere vorhanden" : "vollständig"
}`; }${categoryFilterSummary}`;
} }
if (toolName === "summarize_spending") { if (toolName === "summarize_spending") {
@@ -1552,7 +1756,7 @@ function summarizeToolOutput(toolName: string, output: unknown) {
const categoryCount = Array.isArray(record.categoryBreakdown) const categoryCount = Array.isArray(record.categoryBreakdown)
? record.categoryBreakdown.length ? record.categoryBreakdown.length
: 0; : 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") { if (toolName === "forecast_cashflow") {
@@ -1569,12 +1773,12 @@ function summarizeToolOutput(toolName: string, output: unknown) {
if (toolName === "get_categories") { if (toolName === "get_categories") {
const count = Array.isArray(record.categories) ? record.categories.length : 0; 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") { if (toolName === "detect_recurring_transactions") {
const count = Array.isArray(record.patterns) ? record.patterns.length : 0; 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") { if (toolName === "find_anomalies") {
@@ -1591,7 +1795,7 @@ function summarizeToolOutput(toolName: string, output: unknown) {
if (toolName === "compare_periods") { if (toolName === "compare_periods") {
const deltas = unknownRecord(record.deltas); const deltas = unknownRecord(record.deltas);
const balance = maybeNumber(deltas.balance) ?? 0; const balance = maybeNumber(deltas.balance) ?? 0;
return `Periodenvergleich, Saldo-Differenz ${formatEuro(balance)}`; return `Periodenvergleich, Saldo-Differenz ${formatEuro(balance)}${categoryFilterSummary}`;
} }
if (toolName === "forecast_fixed_costs") { if (toolName === "forecast_fixed_costs") {
@@ -1602,7 +1806,7 @@ function summarizeToolOutput(toolName: string, output: unknown) {
if (toolName === "explain_savings_rate") { if (toolName === "explain_savings_rate") {
const savingsRate = maybeNumber(record.savingsRate) ?? 0; const savingsRate = maybeNumber(record.savingsRate) ?? 0;
const savedAmount = maybeNumber(record.savedAmount) ?? 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"; return "Werkzeug ausgeführt";
@@ -1631,7 +1835,10 @@ const transactionToolInputSchema = z.object({
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."), from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
to: z.string().optional().describe("Optionales Enddatum 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."), 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."), 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."), 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."), 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."), compareFrom: z.string().describe("Startdatum des Vergleichszeitraums im Format YYYY-MM-DD."),
compareTo: z.string().describe("Enddatum 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."), 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."), 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."), type: z.enum(["income", "expense"]).optional().describe("Optional nur Einnahmen oder Ausgaben vergleichen."),
}); });