feat: add guarded savings agent tools
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user