From 3541d008649cd6a461aa429dd7d7afefb4d9b0cd Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Tue, 16 Jun 2026 09:44:37 +0200 Subject: [PATCH] Improve category filter alias resolution --- ...-8 - Fix-savings-tool-category-matching.md | 47 ++++ convex/savingsChat.test.ts | 253 +++++++++++++++++ convex/savingsChat.ts | 258 ++++++++++++++++-- 3 files changed, 534 insertions(+), 24 deletions(-) create mode 100644 backlog/tasks/task-8 - Fix-savings-tool-category-matching.md diff --git a/backlog/tasks/task-8 - Fix-savings-tool-category-matching.md b/backlog/tasks/task-8 - Fix-savings-tool-category-matching.md new file mode 100644 index 0000000..be1132e --- /dev/null +++ b/backlog/tasks/task-8 - Fix-savings-tool-category-matching.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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 + + +## Implementation Plan + + +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 + + +## Implementation Notes + + +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). + diff --git a/convex/savingsChat.test.ts b/convex/savingsChat.test.ts index d0fdfc8..8d601fd 100644 --- a/convex/savingsChat.test.ts +++ b/convex/savingsChat.test.ts @@ -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)", + ); + }); }); diff --git a/convex/savingsChat.ts b/convex/savingsChat.ts index 9a2a0c5..7dc3938 100644 --- a/convex/savingsChat.ts +++ b/convex/savingsChat.ts @@ -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, Doc<"categories">>; accountById: Map, 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, + categoryIds: Set> | 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) { + 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."), });