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 () => {
|
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)",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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."),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user