Improve category filter alias resolution
This commit is contained in:
@@ -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