Improve category filter alias resolution
This commit is contained in:
47
backlog/tasks/task-8 - Fix-savings-tool-category-matching.md
Normal file
47
backlog/tasks/task-8 - Fix-savings-tool-category-matching.md
Normal 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 -->
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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."),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user