feat: add guarded savings agent tools

This commit is contained in:
Matthias
2026-06-23 21:13:02 +02:00
parent 6e687dc9b5
commit b7dae31fe1
22 changed files with 3510 additions and 73 deletions

View File

@@ -1,4 +1,4 @@
import { action, internalQuery, query } from "./_generated/server";
import { action, internalMutation, internalQuery, query } from "./_generated/server";
import { v } from "convex/values";
import { generateText, stepCountIs, tool } from "ai";
import { openai } from "@ai-sdk/openai";
@@ -7,7 +7,7 @@ import { z } from "zod";
import { addMonthsToMonthKey, bookingMonth, monthKeyFromBasis } from "./lib/month";
import { requireUserId } from "./lib/helpers";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, QueryCtx } from "./_generated/server";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
type ChatRole = "user" | "assistant";
type ChatMessage = { role: ChatRole; content: string };
@@ -98,16 +98,19 @@ function formatEuro(value: number): string {
return `${value.toFixed(2)}`;
}
function buildSystemPrompt(context: { from: string; to: string; basis: string; accountName?: string }) {
function buildSystemPrompt(context: { from: string; to: string; basis: string; today: string; accountName?: string }) {
return [
"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.",
"Monatsvorschauen sind Schätzungen; kennzeichne sie klar als Prognose.",
"Wenn du einen Änderungs-Vorschlag vorbereitest, sage ausdrücklich, dass er erst über den Button in der Oberfläche angewendet wird.",
"Antworte auf Deutsch, kurz und handlungsorientiert.",
`Zeitraum: ${context.from} bis ${context.to}.`,
`Basis: ${context.basis}.`,
`Heute: ${context.today}.`,
context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.",
"Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.",
"Nenne keine internen IDs und keine Rohdatenfelder.",
@@ -135,7 +138,7 @@ function sortTransactionsForContext(
}
async function loadMatchingTransactions(
ctx: QueryCtx,
ctx: QueryCtx | MutationCtx,
userId: Id<"users">,
args: ChatContextArgs,
): Promise<Doc<"transactions">[]> {
@@ -458,6 +461,60 @@ const savingsLeverValidator = v.object({
monthlyImpact: v.number(),
});
const previewDriverValidator = v.object({
label: v.string(),
amount: v.number(),
kind: v.union(v.literal("fixed"), v.literal("variable"), v.literal("actual")),
});
const categoryHealthCategoryValidator = v.object({
name: v.string(),
kind: v.union(v.literal("einnahme"), v.literal("ausgabe")),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
isSystem: v.boolean(),
});
const similarCategoryValidator = v.object({
names: v.array(v.string()),
reason: v.string(),
});
const categorySuggestionExampleValidator = v.object({
date: v.string(),
description: v.string(),
counterparty: v.optional(v.string()),
amount: v.number(),
accountName: v.string(),
});
const categorySuggestionValidator = v.object({
categoryName: v.string(),
confidence: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
matchCount: v.number(),
amount: v.number(),
examples: v.array(categorySuggestionExampleValidator),
reason: v.string(),
});
const proposalPreviewRowValidator = v.object({
date: v.optional(v.string()),
description: v.string(),
amount: v.optional(v.number()),
currentCategoryName: v.optional(v.string()),
targetCategoryName: v.optional(v.string()),
action: v.optional(v.string()),
});
const proposalResultValidator = v.object({
planId: v.id("agentActionPlans"),
kind: v.union(v.literal("bulk_recategory"), v.literal("category_changes")),
status: v.literal("pending"),
summary: v.string(),
affectedCount: v.number(),
expiresAt: v.number(),
previewRows: v.array(proposalPreviewRowValidator),
});
export const getContext = query({
args: contextArgsValidator,
returns: contextSummaryValidator,
@@ -496,7 +553,7 @@ function normalizeToolRange(scope: AgentToolScope, from?: string, to?: string) {
return range;
}
async function loadNameMaps(ctx: QueryCtx, userId: Id<"users">) {
async function loadNameMaps(ctx: QueryCtx | MutationCtx, userId: Id<"users">) {
const categories = await ctx.db
.query("categories")
.withIndex("by_user", (index) => index.eq("userId", userId))
@@ -693,7 +750,7 @@ function transactionMatchesToolFilters(
}
async function buildToolTransactionContext(
ctx: QueryCtx,
ctx: QueryCtx | MutationCtx,
userId: Id<"users">,
args: TransactionToolArgs,
): Promise<ToolTransactionContext> {
@@ -850,6 +907,27 @@ function dateForTransaction(tx: Doc<"transactions">) {
return tx.valueDate || tx.bookingDate || tx.effectiveMonth || "n/a";
}
function daysInMonth(month: string) {
const [year, monthNumber] = month.split("-").map(Number);
return new Date(year, monthNumber, 0).getDate();
}
function monthStart(month: string) {
return `${month}-01`;
}
function monthEnd(month: string) {
return `${month}-${String(daysInMonth(month)).padStart(2, "0")}`;
}
function dayOfMonth(date: string) {
return Number(date.slice(8, 10));
}
function previousMonthStart(month: string, monthsBack: number) {
return monthStart(addMonthsToMonthKey(month, -monthsBack));
}
function monthIndexesAreConsecutive(months: string[]) {
if (months.length < 2) return false;
for (let index = 1; index < months.length; index++) {
@@ -1302,6 +1380,508 @@ export const getUncategorizedTransactionsTool = internalQuery({
},
});
export const previewMonthEndSpendingTool = internalQuery({
args: {
scope: toolScopeValidator,
today: v.string(),
accountName: v.optional(v.string()),
},
returns: v.object({
month: v.string(),
today: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
actualIncome: v.number(),
actualExpenses: v.number(),
actualBalance: v.number(),
predictedRemainingFixed: v.number(),
predictedRemainingVariable: v.number(),
predictedRemainingExpenses: v.number(),
expectedMonthExpenses: v.number(),
projectedMonthEndBalance: v.number(),
confidence: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
drivers: v.array(previewDriverValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const month = args.today.slice(0, 7);
const day = Math.max(1, Math.min(dayOfMonth(args.today), daysInMonth(month)));
const remainingDays = Math.max(0, daysInMonth(month) - day);
const currentContext = await buildToolTransactionContext(ctx, userId, {
scope: args.scope,
from: monthStart(month),
to: args.today,
accountName: args.accountName,
});
const historyContext = await buildToolTransactionContext(ctx, userId, {
scope: args.scope,
from: previousMonthStart(month, 3),
to: monthEnd(addMonthsToMonthKey(month, -1)),
accountName: args.accountName,
});
const currentTotals = calculateTotals(currentContext.transactions);
const fixedSeenThisMonth = new Set<Id<"categories">>();
let currentVariableExpenses = 0;
for (const tx of currentContext.transactions) {
if (tx.amount >= 0 || !tx.categoryId) continue;
const category = currentContext.categoryById.get(tx.categoryId);
if (category?.block === "wiederkehrend") fixedSeenThisMonth.add(tx.categoryId);
if (category?.block === "variabel") currentVariableExpenses += tx.amount;
}
const fixedByCategoryMonth = new Map<
Id<"categories">,
{ categoryName: string; amountsByMonth: Map<string, number> }
>();
let historicalVariableTotal = 0;
const historicalMonths = new Set<string>();
for (const tx of historyContext.transactions) {
if (tx.amount >= 0) continue;
const monthKey = monthKeyFromBasis(tx, historyContext.basis);
if (!monthKey) continue;
historicalMonths.add(monthKey);
const category = tx.categoryId ? historyContext.categoryById.get(tx.categoryId) : undefined;
if (category?.block === "wiederkehrend" && tx.categoryId) {
const entry = fixedByCategoryMonth.get(tx.categoryId) ?? {
categoryName: category.name,
amountsByMonth: new Map<string, number>(),
};
entry.amountsByMonth.set(monthKey, (entry.amountsByMonth.get(monthKey) ?? 0) + tx.amount);
fixedByCategoryMonth.set(tx.categoryId, entry);
}
if (category?.block === "variabel") {
historicalVariableTotal += tx.amount;
}
}
const fixedDrivers = [...fixedByCategoryMonth.entries()]
.filter(([categoryId]) => !fixedSeenThisMonth.has(categoryId))
.map(([, entry]) => ({
label: entry.categoryName,
amount: roundMoney(
[...entry.amountsByMonth.values()].reduce((sum, amount) => sum + amount, 0) /
entry.amountsByMonth.size,
),
}))
.filter((entry) => entry.amount < 0)
.sort((a, b) => a.amount - b.amount || a.label.localeCompare(b.label, "de-DE"));
const predictedRemainingFixed = roundMoney(
fixedDrivers.reduce((sum, entry) => sum + entry.amount, 0),
);
const predictedRemainingVariable =
currentVariableExpenses < 0
? roundMoney((currentVariableExpenses / day) * remainingDays)
: roundMoney(
(historicalVariableTotal / Math.max(1, historicalMonths.size) / daysInMonth(month)) *
remainingDays,
);
const predictedRemainingExpenses = roundMoney(predictedRemainingFixed + predictedRemainingVariable);
const confidence: "low" | "medium" | "high" =
day >= 15 && historicalMonths.size >= 2
? "high"
: historicalMonths.size > 0 || day >= 7
? "medium"
: "low";
const drivers = [
...(fixedDrivers.length > 0
? [
{
label: `Noch erwartete Fixkosten: ${fixedDrivers.map((entry) => entry.label).join(", ")}`,
amount: predictedRemainingFixed,
kind: "fixed" as const,
},
]
: []),
...(predictedRemainingVariable < 0
? [
{
label:
currentVariableExpenses < 0
? "Variable Ausgaben auf Basis des laufenden Monats"
: "Variable Ausgaben auf Basis historischer Monate",
amount: predictedRemainingVariable,
kind: "variable" as const,
},
]
: []),
];
return {
month,
today: args.today,
basis: args.scope.basis,
accountName: currentContext.accountName,
actualIncome: currentTotals.income,
actualExpenses: currentTotals.expenses,
actualBalance: currentTotals.balance,
predictedRemainingFixed,
predictedRemainingVariable,
predictedRemainingExpenses,
expectedMonthExpenses: roundMoney(currentTotals.expenses + predictedRemainingExpenses),
projectedMonthEndBalance: roundMoney(currentTotals.balance + predictedRemainingExpenses),
confidence,
drivers,
};
},
});
export const inspectCategoryHealthTool = internalQuery({
args: {
scope: toolScopeValidator,
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
totalCategories: v.number(),
uncategorizedCount: v.number(),
uncategorizedAmount: v.number(),
topUncategorizedCounterparties: v.array(topCounterpartyValidator),
unusedCategories: v.array(categoryHealthCategoryValidator),
similarCategories: v.array(similarCategoryValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const usedCategoryIds = new Set(
context.transactions.flatMap((tx) => (tx.categoryId ? [tx.categoryId] : [])),
);
const uncategorized = context.transactions.filter((tx) => !tx.categoryId);
const counterpartyMap = new Map<string, { count: number; amount: number }>();
for (const tx of uncategorized) {
const name = tx.counterparty?.trim() || tx.description.trim() || "Unbekannt";
const entry = counterpartyMap.get(name) ?? { count: 0, amount: 0 };
entry.count += 1;
entry.amount += tx.amount;
counterpartyMap.set(name, entry);
}
const similarCategories = [];
for (let a = 0; a < context.categories.length; a++) {
for (let b = a + 1; b < context.categories.length; b++) {
const first = context.categories[a];
const second = context.categories[b];
const firstTokens = new Set(categoryTokens(first.name));
const secondTokens = new Set(categoryTokens(second.name));
const overlap = [...firstTokens].filter((token) => secondTokens.has(token));
const subset =
overlap.length > 0 &&
(overlap.length === firstTokens.size || overlap.length === secondTokens.size);
if (!subset) continue;
similarCategories.push({
names: [first.name, second.name].sort((x, y) => x.localeCompare(y, "de-DE")),
reason: "Ähnliche Kategoriebegriffe",
});
}
}
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
totalCategories: context.categories.length,
uncategorizedCount: uncategorized.length,
uncategorizedAmount: roundMoney(uncategorized.reduce((sum, tx) => sum + tx.amount, 0)),
topUncategorizedCounterparties: [...counterpartyMap.entries()]
.map(([name, entry]) => ({ name, count: entry.count, amount: roundMoney(entry.amount) }))
.sort((a, b) => b.count - a.count || Math.abs(b.amount) - Math.abs(a.amount) || a.name.localeCompare(b.name, "de-DE"))
.slice(0, 5),
unusedCategories: context.categories
.filter((category) => !usedCategoryIds.has(category._id))
.map((category) => ({
name: category.name,
kind: category.kind,
block: category.block,
isSystem: category.isSystem,
})),
similarCategories,
};
},
});
export const suggestCategoryForTransactionsTool = internalQuery({
args: {
...transactionToolArgsValidator,
onlyUncategorized: v.optional(v.boolean()),
},
returns: v.object({
from: v.string(),
to: v.string(),
basis: v.union(v.literal("effective"), v.literal("booking")),
accountName: v.optional(v.string()),
suggestions: v.array(categorySuggestionValidator),
}),
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const context = await buildToolTransactionContext(ctx, userId, args);
const limit = clampToolLimit(args.limit);
const candidates = context.transactions
.filter((tx) => (args.onlyUncategorized ? !tx.categoryId : true))
.slice(0, limit);
const history = await loadMatchingTransactions(ctx, userId, {
from: previousMonthStart(context.from.slice(0, 7), 12),
to: context.from,
accountId: context.accountId,
basis: context.basis,
});
const suggestionMap = new Map<
Id<"categories">,
{ categoryName: string; matchCount: number; amount: number; examples: ReturnType<typeof safeTransactionRow>[] }
>();
for (const tx of candidates) {
const txText = [tx.description, tx.counterparty].map(normalizedText).filter(Boolean);
const matches = history.filter((historical) => {
if (!historical.categoryId) return false;
const historicalText = [historical.description, historical.counterparty].map(normalizedText).filter(Boolean);
return txText.some((text) => text && historicalText.includes(text));
});
for (const match of matches) {
const categoryId = match.categoryId;
if (!categoryId) continue;
const category = context.categoryById.get(categoryId);
if (!category) continue;
const entry = suggestionMap.get(categoryId) ?? {
categoryName: category.name,
matchCount: 0,
amount: 0,
examples: [],
};
entry.matchCount += 1;
entry.amount += tx.amount;
if (entry.examples.length < 3) entry.examples.push(safeTransactionRow(tx, context));
suggestionMap.set(categoryId, entry);
}
}
return {
from: context.from,
to: context.to,
basis: context.basis,
accountName: context.accountName,
suggestions: [...suggestionMap.values()]
.map((entry) => ({
categoryName: entry.categoryName,
confidence: entry.matchCount >= 1 ? "high" as const : "medium" as const,
matchCount: entry.examples.length,
amount: roundMoney(entry.amount),
examples: entry.examples.map((row) => ({
date: row.date,
description: row.description,
counterparty: row.counterparty,
amount: row.amount,
accountName: row.accountName,
})),
reason: "Ähnliche frühere Umsätze waren bereits dieser Kategorie zugeordnet.",
}))
.sort((a, b) => b.matchCount - a.matchCount || Math.abs(b.amount) - Math.abs(a.amount))
.slice(0, 5),
};
},
});
async function requireOwnedChatSession(
ctx: MutationCtx,
sessionId: Id<"chatSessions">,
userId: Id<"users">,
) {
const session = await ctx.db.get(sessionId);
if (!session) throw new Error("Chat nicht gefunden");
if (session.userId !== userId) throw new Error("Nicht autorisiert");
if (session.isDeleted) throw new Error("Chat nicht gefunden");
}
export const proposeBulkRecategoryTool = internalMutation({
args: {
scope: toolScopeValidator,
sessionId: v.id("chatSessions"),
targetCategoryName: v.string(),
from: v.optional(v.string()),
to: v.optional(v.string()),
accountId: v.optional(v.id("accounts")),
accountName: v.optional(v.string()),
search: v.optional(v.string()),
onlyUncategorized: v.optional(v.boolean()),
limit: v.optional(v.number()),
now: v.optional(v.number()),
},
returns: proposalResultValidator,
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await requireOwnedChatSession(ctx, args.sessionId, userId);
const targetCategory = await ctx.db
.query("categories")
.withIndex("by_user_name", (index) =>
index.eq("userId", userId).eq("name", args.targetCategoryName.trim()),
)
.unique();
if (!targetCategory) throw new Error("Zielkategorie nicht gefunden");
const context = await buildToolTransactionContext(ctx, userId, args);
const limit = Math.min(100, clampToolLimit(args.limit));
const transactions = context.transactions
.filter((tx) => (args.onlyUncategorized ? !tx.categoryId : true))
.filter((tx) => tx.categoryId !== targetCategory._id)
.slice(0, limit);
if (transactions.length === 0) throw new Error("Keine passenden Umsätze gefunden");
const operations = transactions.map((tx) => ({
type: "set_transaction_category" as const,
transactionId: tx._id,
fromCategoryId: tx.categoryId,
toCategoryId: targetCategory._id,
}));
const previewRows = transactions.slice(0, 10).map((tx) => ({
date: dateForTransaction(tx),
description: tx.description,
amount: roundMoney(tx.amount),
currentCategoryName: tx.categoryId
? context.categoryById.get(tx.categoryId)?.name ?? "Ohne Kategorie"
: "Ohne Kategorie",
targetCategoryName: targetCategory.name,
}));
const now = args.now ?? Date.now();
const summary = `${operations.length} Umsatz${operations.length === 1 ? "" : "e"} zur Kategorie ${targetCategory.name} zuordnen`;
const planId = await ctx.db.insert("agentActionPlans", {
userId,
sessionId: args.sessionId,
kind: "bulk_recategory",
status: "pending",
summary,
affectedCount: operations.length,
createdAt: now,
expiresAt: now + 24 * 60 * 60 * 1000,
operations,
previewRows,
});
return {
planId,
kind: "bulk_recategory" as const,
status: "pending" as const,
summary,
affectedCount: operations.length,
expiresAt: now + 24 * 60 * 60 * 1000,
previewRows,
};
},
});
const categoryCreateValidator = v.object({
name: v.string(),
kind: v.union(v.literal("einnahme"), v.literal("ausgabe")),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
color: v.string(),
icon: v.optional(v.string()),
sortOrder: v.number(),
});
const categoryUpdateByNameValidator = v.object({
name: v.string(),
newName: v.optional(v.string()),
kind: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
color: v.optional(v.string()),
icon: v.optional(v.string()),
sortOrder: v.optional(v.number()),
});
export const proposeCategoryChangesTool = internalMutation({
args: {
sessionId: v.id("chatSessions"),
creates: v.optional(v.array(categoryCreateValidator)),
updates: v.optional(v.array(categoryUpdateByNameValidator)),
now: v.optional(v.number()),
},
returns: proposalResultValidator,
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await requireOwnedChatSession(ctx, args.sessionId, userId);
const operations = [];
const previewRows = [];
for (const create of (args.creates ?? []).slice(0, 100)) {
const name = create.name.trim();
if (!name || (create.kind === "ausgabe" && !create.block)) continue;
operations.push({
type: "create_category" as const,
name,
kind: create.kind,
block: create.kind === "ausgabe" ? create.block : undefined,
color: create.color,
icon: create.icon,
sortOrder: create.sortOrder,
});
previewRows.push({
description: name,
action: "Kategorie erstellen",
targetCategoryName: name,
});
}
for (const update of (args.updates ?? []).slice(0, 100 - operations.length)) {
const trimmedNewName = update.newName?.trim();
if (update.newName !== undefined && !trimmedNewName) continue;
const category = await ctx.db
.query("categories")
.withIndex("by_user_name", (index) =>
index.eq("userId", userId).eq("name", update.name.trim()),
)
.unique();
if (!category) continue;
operations.push({
type: "update_category" as const,
categoryId: category._id,
name: trimmedNewName,
kind: update.kind,
block: update.block,
color: update.color,
icon: update.icon,
sortOrder: update.sortOrder,
});
previewRows.push({
description: category.name,
action: "Kategorie aktualisieren",
currentCategoryName: category.name,
targetCategoryName: trimmedNewName ?? category.name,
});
}
if (operations.length === 0) throw new Error("Keine Kategorieänderungen vorbereitet");
const now = args.now ?? Date.now();
const summary = `${operations.length} Kategorieänderung${operations.length === 1 ? "" : "en"} vorbereiten`;
const planId = await ctx.db.insert("agentActionPlans", {
userId,
sessionId: args.sessionId,
kind: "category_changes",
status: "pending",
summary,
affectedCount: operations.length,
createdAt: now,
expiresAt: now + 24 * 60 * 60 * 1000,
operations,
previewRows,
});
return {
planId,
kind: "category_changes" as const,
status: "pending" as const,
summary,
affectedCount: operations.length,
expiresAt: now + 24 * 60 * 60 * 1000,
previewRows,
};
},
});
export const comparePeriodsTool = internalQuery({
args: {
scope: toolScopeValidator,
@@ -1701,13 +2281,17 @@ function summarizeToolInput(input: unknown) {
const search = maybeString(record.search);
const limit = maybeNumber(record.limit);
const horizonMonths = maybeNumber(record.horizonMonths);
const today = maybeString(record.today);
const targetCategoryName = maybeString(record.targetCategoryName);
const type = maybeString(record.type);
const categoryNames = Array.isArray(record.categoryNames)
? record.categoryNames.filter((name): name is string => typeof name === "string")
: [];
if (from || to) parts.push(`${from ?? "?"} bis ${to ?? "?"}`);
if (today) parts.push(`Monatsvorschau für ${today}`);
if (search) parts.push(`Suche "${search}"`);
if (targetCategoryName) parts.push(`Zielkategorie ${targetCategoryName}`);
if (categoryNames.length > 0) parts.push(`Kategorien ${categoryNames.join(", ")}`);
if (type) parts.push(type === "income" ? "Einnahmen" : "Ausgaben");
if (horizonMonths) parts.push(`${horizonMonths} Monate Prognose`);
@@ -1822,6 +2406,31 @@ function summarizeToolOutput(toolName: string, output: unknown) {
return `Sparquote ${(savingsRate * 100).toFixed(1)}%, gespart ${formatEuro(savedAmount)}${categoryFilterSummary}`;
}
if (toolName === "preview_month_end_spending") {
const month = maybeString(record.month) ?? "unbekannt";
const expectedMonthExpenses = maybeNumber(record.expectedMonthExpenses) ?? 0;
const predictedRemainingExpenses = maybeNumber(record.predictedRemainingExpenses) ?? 0;
return `Monatsvorschau ${month}: erwartet ${formatEuro(expectedMonthExpenses)} Ausgaben, Rest ${formatEuro(predictedRemainingExpenses)}`;
}
if (toolName === "inspect_category_health") {
const uncategorizedCount = maybeNumber(record.uncategorizedCount) ?? 0;
const unusedCategories = Array.isArray(record.unusedCategories) ? record.unusedCategories.length : 0;
const similarCategories = Array.isArray(record.similarCategories) ? record.similarCategories.length : 0;
return `${uncategorizedCount} unklassifizierte Umsätze, ${unusedCategories} ungenutzte Kategorien, ${similarCategories} ähnliche Gruppen`;
}
if (toolName === "suggest_category_for_transactions") {
const count = Array.isArray(record.suggestions) ? record.suggestions.length : 0;
return `${count} ${count === 1 ? "Kategorie-Vorschlag" : "Kategorie-Vorschläge"} gefunden`;
}
if (toolName === "propose_bulk_recategory" || toolName === "propose_category_changes") {
const affectedCount = maybeNumber(record.affectedCount) ?? 0;
const summary = maybeString(record.summary) ?? "Vorschlag vorbereitet";
return `${summary}, ${affectedCount} Änderung${affectedCount === 1 ? "" : "en"} warten auf Bestätigung`;
}
return "Werkzeug ausgeführt";
}
@@ -1936,9 +2545,59 @@ const fixedCostsForecastToolInputSchema = z.object({
asOf: z.string().optional().describe("Stichtag für den Start der Prognose im Format YYYY-MM-DD."),
});
const monthEndPreviewToolInputSchema = z.object({
today: z.string().describe("Lokales heutiges Datum im Format YYYY-MM-DD."),
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
});
const categoryHealthToolInputSchema = 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."),
});
const categorySuggestionToolInputSchema = 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."),
search: z.string().optional().describe("Suchtext für Beschreibung, Gegenpartei oder Kategorie."),
onlyUncategorized: z.boolean().optional().describe("Nur Umsätze ohne Kategorie vorschlagen."),
limit: z.number().int().min(1).max(MAX_TOOL_ROW_LIMIT).optional().describe("Maximale Anzahl geprüfter Umsatzzeilen."),
});
const bulkRecategoryProposalToolInputSchema = z.object({
targetCategoryName: z.string().describe("Bestehende Zielkategorie, der die gefundenen Umsätze zugeordnet werden sollen."),
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."),
search: z.string().optional().describe("Suchtext für die zuzuordnenden Umsätze."),
onlyUncategorized: z.boolean().optional().describe("Nur Umsätze ohne Kategorie in den Vorschlag aufnehmen."),
limit: z.number().int().min(1).max(MAX_TOOL_ROW_LIMIT).optional().describe("Maximale Anzahl Änderungen."),
});
const categoryChangeProposalToolInputSchema = z.object({
creates: z.array(z.object({
name: z.string(),
kind: z.enum(["einnahme", "ausgabe"]),
block: z.enum(["wiederkehrend", "variabel"]).optional(),
color: z.string(),
icon: z.string().optional(),
sortOrder: z.number(),
})).optional().describe("Neue Kategorien, die nach UI-Bestätigung erstellt werden sollen."),
updates: z.array(z.object({
name: z.string(),
newName: z.string().optional(),
kind: z.enum(["einnahme", "ausgabe"]).optional(),
block: z.enum(["wiederkehrend", "variabel"]).optional(),
color: z.string().optional(),
icon: z.string().optional(),
sortOrder: z.number().optional(),
})).optional().describe("Bestehende Kategorien nach Name, die nach UI-Bestätigung geändert werden sollen."),
});
async function generateSavingsChatResponse(
ctx: ActionCtx,
args: ChatContextArgs & { messages: ChatMessage[] },
args: ChatContextArgs & { messages: ChatMessage[]; today?: string; sessionId?: Id<"chatSessions"> },
): Promise<ChatAskResult> {
if (args.messages.length === 0) {
throw new Error("Kein Nutzernachrichttext vorhanden.");
@@ -1957,6 +2616,7 @@ async function generateSavingsChatResponse(
accountId: args.accountId,
basis: args.basis,
};
const today = args.today ?? new Date().toISOString().slice(0, 10);
const selectedSummary: {
totalCount: number;
@@ -1975,6 +2635,7 @@ async function generateSavingsChatResponse(
from: args.from,
to: args.to,
basis: args.basis,
today,
accountName: selectedSummary.accountName,
});
@@ -2089,6 +2750,62 @@ async function generateSavingsChatResponse(
...input,
}),
}),
inspect_category_health: tool({
description:
"Analysiert read-only Kategoriequalität: unklassifizierte Umsätze, ungenutzte Kategorien, ähnliche Kategorien und Top-Gegenparteien ohne Kategorie.",
inputSchema: categoryHealthToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.inspectCategoryHealthTool, {
scope,
...input,
}),
}),
suggest_category_for_transactions: tool({
description:
"Schlägt read-only Kategorien für passende oder unklassifizierte Umsätze vor. Nutze es vor Änderungs-Vorschlägen, um eine sichere Zielkategorie zu begründen.",
inputSchema: categorySuggestionToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.suggestCategoryForTransactionsTool, {
scope,
...input,
}),
}),
preview_month_end_spending: tool({
description:
"Berechnet eine deterministische Monatsvorschau vom lokalen heutigen Datum bis Monatsende. Nutze dieses Tool für vermutete Ausgaben im Rest des aktuellen Monats.",
inputSchema: monthEndPreviewToolInputSchema,
execute: async (input) =>
await ctx.runQuery(internal.savingsChat.previewMonthEndSpendingTool, {
scope,
today: input.today || today,
accountName: input.accountName,
}),
}),
propose_bulk_recategory: tool({
description:
"Erstellt nur einen bestätigungspflichtigen Vorschlag, um passende Umsätze einer bestehenden Kategorie zuzuordnen. Das Tool ändert keine Daten direkt.",
inputSchema: bulkRecategoryProposalToolInputSchema,
execute: async (input) => {
if (!args.sessionId) throw new Error("Änderungs-Vorschläge sind nur in gespeicherten Chats möglich.");
return await ctx.runMutation(internal.savingsChat.proposeBulkRecategoryTool, {
scope,
sessionId: args.sessionId,
...input,
});
},
}),
propose_category_changes: tool({
description:
"Erstellt nur einen bestätigungspflichtigen Vorschlag für Kategorie-Erstellung oder Kategorie-Updates. Löschen wird nicht unterstützt.",
inputSchema: categoryChangeProposalToolInputSchema,
execute: async (input) => {
if (!args.sessionId) throw new Error("Änderungs-Vorschläge sind nur in gespeicherten Chats möglich.");
return await ctx.runMutation(internal.savingsChat.proposeCategoryChangesTool, {
sessionId: args.sessionId,
...input,
});
},
}),
};
const envModel = process.env.SAVINGS_CHAT_MODEL?.trim();
@@ -2144,6 +2861,7 @@ export const ask = action({
to: v.string(),
accountId: v.optional(v.id("accounts")),
basis: v.union(v.literal("effective"), v.literal("booking")),
today: v.optional(v.string()),
},
returns: v.object({
model: v.string(),
@@ -2161,6 +2879,7 @@ export const ask = action({
handler: async (ctx, args): Promise<ChatAskResult> => {
return await generateSavingsChatResponse(ctx, {
...args,
today: args.today,
messages: args.messages.map((message) => ({
role: normalizeRole(message.role),
content: message.content,
@@ -2177,6 +2896,7 @@ export const sendMessage = action({
to: v.string(),
accountId: v.optional(v.id("accounts")),
basis: v.union(v.literal("effective"), v.literal("booking")),
today: v.optional(v.string()),
},
returns: v.object({
model: v.string(),
@@ -2215,6 +2935,8 @@ export const sendMessage = action({
to: args.to,
accountId: args.accountId,
basis: args.basis,
today: args.today,
sessionId: args.sessionId,
messages,
});
} catch (error) {