feat: add read-only savings agent tools
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { action, internalQuery, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { generateText } from "ai";
|
||||
import { generateText, stepCountIs, tool } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { internal } from "./_generated/api";
|
||||
import { bookingMonth } from "./lib/month";
|
||||
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 { QueryCtx } from "./_generated/server";
|
||||
@@ -17,7 +18,9 @@ const chatMessageValidator = v.object({
|
||||
});
|
||||
|
||||
const MAX_CONVERSATION_MESSAGES = 20;
|
||||
const MAX_PROMPT_CHARACTERS = 180_000;
|
||||
const DEFAULT_TOOL_ROW_LIMIT = 50;
|
||||
const MAX_TOOL_ROW_LIMIT = 200;
|
||||
const MAX_TOOL_RANGE_MONTHS = 18;
|
||||
|
||||
type ChatContextArgs = {
|
||||
from: string;
|
||||
@@ -42,6 +45,34 @@ type ChatAskResult = {
|
||||
answer: string;
|
||||
usedTransactions: number;
|
||||
usedBalance: { income: number; expenses: number; balance: number };
|
||||
toolTrace: ToolTrace[];
|
||||
};
|
||||
type ToolTrace = { name: string; inputSummary: string; resultSummary: string };
|
||||
type TransactionTypeFilter = "income" | "expense";
|
||||
type AgentToolScope = ChatContextArgs;
|
||||
type TransactionToolArgs = {
|
||||
scope: AgentToolScope;
|
||||
from?: string;
|
||||
to?: string;
|
||||
accountId?: Id<"accounts">;
|
||||
accountName?: string;
|
||||
categoryIds?: Id<"categories">[];
|
||||
categoryNames?: string[];
|
||||
search?: string;
|
||||
type?: TransactionTypeFilter;
|
||||
limit?: number;
|
||||
};
|
||||
type ToolTransactionContext = {
|
||||
from: string;
|
||||
to: string;
|
||||
basis: AgentToolScope["basis"];
|
||||
accountId?: Id<"accounts">;
|
||||
accountName?: string;
|
||||
categories: Doc<"categories">[];
|
||||
accounts: Doc<"accounts">[];
|
||||
categoryById: Map<Id<"categories">, Doc<"categories">>;
|
||||
accountById: Map<Id<"accounts">, Doc<"accounts">>;
|
||||
transactions: Doc<"transactions">[];
|
||||
};
|
||||
|
||||
function formatEuro(value: number): string {
|
||||
@@ -51,37 +82,18 @@ function formatEuro(value: number): string {
|
||||
function buildSystemPrompt(context: { from: string; to: string; basis: string; accountName?: string }) {
|
||||
return [
|
||||
"Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.",
|
||||
"Nutze ausschließlich die gelieferten Umsätze als Kontext und beziehe dich nur auf die angegebenen Werte.",
|
||||
"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.",
|
||||
"Antworte auf Deutsch, kurz und handlungsorientiert.",
|
||||
`Zeitraum: ${context.from} bis ${context.to}.`,
|
||||
`Basis: ${context.basis}.`,
|
||||
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.",
|
||||
"Verwende keine Links, keine HTML-Tags und keine Emojis.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function buildPrompt(context: ChatPromptContext, conversation: ChatMessage[]) {
|
||||
return [
|
||||
"Kontext der Auswertung:",
|
||||
`Zeitraum: ${context.from} bis ${context.to}`,
|
||||
`Basis: ${context.basis}`,
|
||||
`Konto: ${context.accountName ?? "Alle Konten"}`,
|
||||
`Anzahl Umsätze: ${context.totals.transactionCount}`,
|
||||
`Einnahmen: ${formatEuro(context.totals.income)}`,
|
||||
`Ausgaben: ${formatEuro(context.totals.expenses)}`,
|
||||
`Saldo: ${formatEuro(context.totals.balance)}`,
|
||||
"",
|
||||
"Umsätze (neueste zuerst):",
|
||||
...(context.transactionLines.length > 0
|
||||
? context.transactionLines
|
||||
: ["Keine Umsätze im Zeitraum."]),
|
||||
"",
|
||||
"Gesprächsverlauf:",
|
||||
...conversation.map((message) => `${message.role}: ${message.content}`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function normalizeRole(role: ChatRole): "user" | "assistant" {
|
||||
return role;
|
||||
}
|
||||
@@ -253,6 +265,55 @@ const contextSummaryValidator = v.object({
|
||||
isComplete: v.literal(true),
|
||||
});
|
||||
|
||||
const toolTraceValidator = v.object({
|
||||
name: v.string(),
|
||||
inputSummary: v.string(),
|
||||
resultSummary: v.string(),
|
||||
});
|
||||
|
||||
const toolScopeValidator = v.object(contextArgsValidator);
|
||||
|
||||
const transactionTypeFilterValidator = v.union(v.literal("income"), v.literal("expense"));
|
||||
|
||||
const transactionToolArgsValidator = {
|
||||
scope: toolScopeValidator,
|
||||
from: v.optional(v.string()),
|
||||
to: v.optional(v.string()),
|
||||
accountId: v.optional(v.id("accounts")),
|
||||
accountName: v.optional(v.string()),
|
||||
categoryIds: v.optional(v.array(v.id("categories"))),
|
||||
categoryNames: v.optional(v.array(v.string())),
|
||||
search: v.optional(v.string()),
|
||||
type: v.optional(transactionTypeFilterValidator),
|
||||
limit: v.optional(v.number()),
|
||||
};
|
||||
|
||||
const safeTransactionRowValidator = v.object({
|
||||
date: v.string(),
|
||||
bookingDate: v.optional(v.string()),
|
||||
effectiveMonth: v.optional(v.string()),
|
||||
description: v.string(),
|
||||
counterparty: v.optional(v.string()),
|
||||
amount: v.number(),
|
||||
categoryName: v.string(),
|
||||
accountName: v.string(),
|
||||
isPending: v.boolean(),
|
||||
});
|
||||
|
||||
const monthlyTrendValidator = v.object({
|
||||
month: v.string(),
|
||||
income: v.number(),
|
||||
expenses: v.number(),
|
||||
balance: v.number(),
|
||||
});
|
||||
|
||||
const categoryBreakdownValidator = v.object({
|
||||
categoryId: v.optional(v.id("categories")),
|
||||
name: v.string(),
|
||||
amount: v.number(),
|
||||
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
|
||||
});
|
||||
|
||||
export const getContext = query({
|
||||
args: contextArgsValidator,
|
||||
returns: contextSummaryValidator,
|
||||
@@ -263,6 +324,211 @@ export const getContext = query({
|
||||
},
|
||||
});
|
||||
|
||||
function parseMonthIndex(month: string) {
|
||||
const [year, monthNumber] = month.split("-").map(Number);
|
||||
return year * 12 + monthNumber;
|
||||
}
|
||||
|
||||
function rangeMonthSpan(from: string, to: string) {
|
||||
return parseMonthIndex(to.slice(0, 7)) - parseMonthIndex(from.slice(0, 7)) + 1;
|
||||
}
|
||||
|
||||
function clampToolLimit(limit: number | undefined) {
|
||||
if (limit === undefined) return DEFAULT_TOOL_ROW_LIMIT;
|
||||
return Math.max(1, Math.min(MAX_TOOL_ROW_LIMIT, Math.floor(limit)));
|
||||
}
|
||||
|
||||
function normalizeToolRange(scope: AgentToolScope, from?: string, to?: string) {
|
||||
const range = {
|
||||
from: from?.trim() || scope.from,
|
||||
to: to?.trim() || scope.to,
|
||||
};
|
||||
if (range.from > range.to) {
|
||||
throw new Error("Ungültiger Zeitraum: von-Datum liegt nach bis-Datum.");
|
||||
}
|
||||
if (rangeMonthSpan(range.from, range.to) > MAX_TOOL_RANGE_MONTHS) {
|
||||
throw new Error(`Der Tool-Zeitraum darf maximal ${MAX_TOOL_RANGE_MONTHS} Monate umfassen.`);
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
async function loadNameMaps(ctx: QueryCtx, userId: Id<"users">) {
|
||||
const categories = await ctx.db
|
||||
.query("categories")
|
||||
.withIndex("by_user", (index) => index.eq("userId", userId))
|
||||
.collect();
|
||||
const accounts = await ctx.db
|
||||
.query("accounts")
|
||||
.withIndex("by_user", (index) => index.eq("userId", userId))
|
||||
.collect();
|
||||
|
||||
return {
|
||||
categories,
|
||||
accounts,
|
||||
categoryById: new Map(categories.map((category) => [category._id, category])),
|
||||
accountById: new Map(accounts.map((account) => [account._id, account])),
|
||||
};
|
||||
}
|
||||
|
||||
function findAccountIdByName(
|
||||
accounts: Doc<"accounts">[],
|
||||
accountName: string | undefined,
|
||||
): Id<"accounts"> | undefined {
|
||||
const normalized = accountName?.trim().toLocaleLowerCase("de-DE");
|
||||
if (!normalized) return undefined;
|
||||
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;
|
||||
}
|
||||
|
||||
function transactionMatchesToolFilters(
|
||||
tx: Doc<"transactions">,
|
||||
args: TransactionToolArgs,
|
||||
context: Pick<ToolTransactionContext, "categoryById" | "accountById">,
|
||||
) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const search = args.search?.trim().toLocaleLowerCase("de-DE");
|
||||
if (search) {
|
||||
const categoryName = tx.categoryId ? context.categoryById.get(tx.categoryId)?.name : "";
|
||||
const accountName = tx.accountId ? context.accountById.get(tx.accountId)?.name : "";
|
||||
const haystack = [
|
||||
tx.description,
|
||||
tx.counterparty,
|
||||
categoryName,
|
||||
accountName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("de-DE");
|
||||
if (!haystack.includes(search)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function buildToolTransactionContext(
|
||||
ctx: QueryCtx,
|
||||
userId: Id<"users">,
|
||||
args: TransactionToolArgs,
|
||||
): Promise<ToolTransactionContext> {
|
||||
const maps = await loadNameMaps(ctx, userId);
|
||||
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 transactions = (await loadMatchingTransactions(ctx, userId, {
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
accountId,
|
||||
basis: args.scope.basis,
|
||||
})).filter((tx) => transactionMatchesToolFilters(tx, args, maps));
|
||||
|
||||
return {
|
||||
...maps,
|
||||
...range,
|
||||
basis: args.scope.basis,
|
||||
accountId,
|
||||
accountName: account?.name,
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
|
||||
function safeTransactionRow(
|
||||
tx: Doc<"transactions">,
|
||||
context: Pick<ToolTransactionContext, "categoryById" | "accountById">,
|
||||
) {
|
||||
return {
|
||||
date: tx.valueDate || tx.bookingDate || tx.effectiveMonth || "n/a",
|
||||
bookingDate: tx.bookingDate,
|
||||
effectiveMonth: tx.effectiveMonth,
|
||||
description: tx.description,
|
||||
counterparty: tx.counterparty,
|
||||
amount: Math.round(tx.amount * 100) / 100,
|
||||
categoryName: tx.categoryId
|
||||
? context.categoryById.get(tx.categoryId)?.name ?? "Ohne Kategorie"
|
||||
: "Ohne Kategorie",
|
||||
accountName: tx.accountId
|
||||
? context.accountById.get(tx.accountId)?.name ?? "Ohne Konto"
|
||||
: "Ohne Konto",
|
||||
isPending: tx.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
function roundMoney(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function summarizeTransactions(context: ToolTransactionContext) {
|
||||
const monthlyMap = new Map<string, { income: number; expenses: number }>();
|
||||
const categoryMap = new Map<
|
||||
string,
|
||||
{ categoryId?: Id<"categories">; name: string; amount: number; block?: "wiederkehrend" | "variabel" }
|
||||
>();
|
||||
let fixedCosts = 0;
|
||||
let variableCosts = 0;
|
||||
|
||||
for (const tx of context.transactions) {
|
||||
const month = monthKeyFromBasis(tx, context.basis);
|
||||
if (month) {
|
||||
const monthEntry = monthlyMap.get(month) ?? { income: 0, expenses: 0 };
|
||||
if (tx.amount > 0) monthEntry.income += tx.amount;
|
||||
if (tx.amount < 0) monthEntry.expenses += tx.amount;
|
||||
monthlyMap.set(month, monthEntry);
|
||||
}
|
||||
|
||||
if (tx.amount >= 0) continue;
|
||||
const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined;
|
||||
if (category?.block === "wiederkehrend") fixedCosts += tx.amount;
|
||||
if (category?.block === "variabel") variableCosts += tx.amount;
|
||||
|
||||
const key = tx.categoryId ?? "none";
|
||||
const categoryEntry = categoryMap.get(key) ?? {
|
||||
categoryId: tx.categoryId,
|
||||
name: category?.name ?? "Ohne Kategorie",
|
||||
amount: 0,
|
||||
block: category?.block,
|
||||
};
|
||||
categoryEntry.amount += tx.amount;
|
||||
categoryMap.set(key, categoryEntry);
|
||||
}
|
||||
|
||||
return {
|
||||
totals: calculateTotals(context.transactions),
|
||||
fixedCosts: roundMoney(fixedCosts),
|
||||
variableCosts: roundMoney(variableCosts),
|
||||
monthlyTrend: [...monthlyMap.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, data]) => ({
|
||||
month,
|
||||
income: roundMoney(data.income),
|
||||
expenses: roundMoney(data.expenses),
|
||||
balance: roundMoney(data.income + data.expenses),
|
||||
})),
|
||||
categoryBreakdown: [...categoryMap.values()]
|
||||
.sort((a, b) => a.amount - b.amount)
|
||||
.map((entry) => ({ ...entry, amount: roundMoney(entry.amount) })),
|
||||
};
|
||||
}
|
||||
|
||||
function toDisplayContextLine(
|
||||
tx: Doc<"transactions">,
|
||||
categoryById: Map<Id<"categories">, string>,
|
||||
@@ -278,6 +544,135 @@ function toDisplayContextLine(
|
||||
}`;
|
||||
}
|
||||
|
||||
export const getTransactionsTool = internalQuery({
|
||||
args: transactionToolArgsValidator,
|
||||
returns: v.object({
|
||||
from: v.string(),
|
||||
to: v.string(),
|
||||
basis: v.union(v.literal("effective"), v.literal("booking")),
|
||||
accountName: v.optional(v.string()),
|
||||
totalCount: v.number(),
|
||||
hasMore: v.boolean(),
|
||||
totals: totalsValidator,
|
||||
rows: v.array(safeTransactionRowValidator),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const context = await buildToolTransactionContext(ctx, userId, args);
|
||||
const limit = clampToolLimit(args.limit);
|
||||
const rows = context.transactions
|
||||
.slice(0, limit)
|
||||
.map((tx) => safeTransactionRow(tx, context));
|
||||
|
||||
return {
|
||||
from: context.from,
|
||||
to: context.to,
|
||||
basis: context.basis,
|
||||
accountName: context.accountName,
|
||||
totalCount: context.transactions.length,
|
||||
hasMore: context.transactions.length > limit,
|
||||
totals: calculateTotals(context.transactions),
|
||||
rows,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const summarizeSpendingTool = 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()),
|
||||
categoryIds: v.optional(v.array(v.id("categories"))),
|
||||
categoryNames: v.optional(v.array(v.string())),
|
||||
search: v.optional(v.string()),
|
||||
type: v.optional(transactionTypeFilterValidator),
|
||||
},
|
||||
returns: v.object({
|
||||
from: v.string(),
|
||||
to: v.string(),
|
||||
basis: v.union(v.literal("effective"), v.literal("booking")),
|
||||
accountName: v.optional(v.string()),
|
||||
totals: totalsValidator,
|
||||
fixedCosts: v.number(),
|
||||
variableCosts: v.number(),
|
||||
monthlyTrend: v.array(monthlyTrendValidator),
|
||||
categoryBreakdown: v.array(categoryBreakdownValidator),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const context = await buildToolTransactionContext(ctx, userId, args);
|
||||
const summary = summarizeTransactions(context);
|
||||
|
||||
return {
|
||||
from: context.from,
|
||||
to: context.to,
|
||||
basis: context.basis,
|
||||
accountName: context.accountName,
|
||||
...summary,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const forecastCashflowTool = 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()),
|
||||
horizonMonths: v.optional(v.number()),
|
||||
asOf: 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()),
|
||||
baselineMonths: v.array(v.string()),
|
||||
excludedPartialMonth: v.union(v.string(), v.null()),
|
||||
monthlyAverage: v.object({
|
||||
income: v.number(),
|
||||
expenses: v.number(),
|
||||
balance: v.number(),
|
||||
}),
|
||||
projection: v.array(monthlyTrendValidator),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const context = await buildToolTransactionContext(ctx, userId, args);
|
||||
const summary = summarizeTransactions(context);
|
||||
const asOfMonth = (args.asOf ?? new Date().toISOString().slice(0, 10)).slice(0, 7);
|
||||
const baseline = summary.monthlyTrend.filter((month) => month.month < asOfMonth);
|
||||
const excludedPartialMonth = summary.monthlyTrend.some((month) => month.month === asOfMonth)
|
||||
? asOfMonth
|
||||
: null;
|
||||
const horizonMonths = Math.max(1, Math.min(3, Math.floor(args.horizonMonths ?? 3)));
|
||||
const denominator = baseline.length || 1;
|
||||
const monthlyAverage = {
|
||||
income: roundMoney(baseline.reduce((sum, month) => sum + month.income, 0) / denominator),
|
||||
expenses: roundMoney(baseline.reduce((sum, month) => sum + month.expenses, 0) / denominator),
|
||||
balance: roundMoney(baseline.reduce((sum, month) => sum + month.balance, 0) / denominator),
|
||||
};
|
||||
const projection = Array.from({ length: horizonMonths }, (_, index) => ({
|
||||
month: addMonthsToMonthKey(asOfMonth, index + 1),
|
||||
...monthlyAverage,
|
||||
}));
|
||||
|
||||
return {
|
||||
from: context.from,
|
||||
to: context.to,
|
||||
basis: context.basis,
|
||||
accountName: context.accountName,
|
||||
baselineMonths: baseline.map((month) => month.month),
|
||||
excludedPartialMonth,
|
||||
monthlyAverage,
|
||||
projection,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getPromptContext = internalQuery({
|
||||
args: contextArgsValidator,
|
||||
returns: v.object({
|
||||
@@ -308,6 +703,117 @@ export const getPromptContext = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
function unknownRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function maybeString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function maybeNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function summarizeToolInput(input: unknown) {
|
||||
const record = unknownRecord(input);
|
||||
const parts = [];
|
||||
const from = maybeString(record.from);
|
||||
const to = maybeString(record.to);
|
||||
const search = maybeString(record.search);
|
||||
const limit = maybeNumber(record.limit);
|
||||
const horizonMonths = maybeNumber(record.horizonMonths);
|
||||
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 (search) parts.push(`Suche "${search}"`);
|
||||
if (categoryNames.length > 0) parts.push(`Kategorien ${categoryNames.join(", ")}`);
|
||||
if (type) parts.push(type === "income" ? "Einnahmen" : "Ausgaben");
|
||||
if (horizonMonths) parts.push(`${horizonMonths} Monate Prognose`);
|
||||
if (limit) parts.push(`Limit ${limit}`);
|
||||
|
||||
return parts.length > 0 ? parts.join(", ") : "Standard-Kontext";
|
||||
}
|
||||
|
||||
function totalsFromOutput(output: Record<string, unknown>) {
|
||||
const totals = unknownRecord(output.totals);
|
||||
return {
|
||||
count: maybeNumber(output.totalCount) ?? maybeNumber(totals.transactionCount) ?? 0,
|
||||
balance: maybeNumber(totals.balance) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeToolOutput(toolName: string, output: unknown) {
|
||||
const record = unknownRecord(output);
|
||||
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"
|
||||
}`;
|
||||
}
|
||||
|
||||
if (toolName === "summarize_spending") {
|
||||
const totals = totalsFromOutput(record);
|
||||
const categoryCount = Array.isArray(record.categoryBreakdown)
|
||||
? record.categoryBreakdown.length
|
||||
: 0;
|
||||
return `${totals.count} Umsätze, Saldo ${formatEuro(totals.balance)}, ${categoryCount} Kategorien`;
|
||||
}
|
||||
|
||||
if (toolName === "forecast_cashflow") {
|
||||
const projectionCount = Array.isArray(record.projection) ? record.projection.length : 0;
|
||||
const average = unknownRecord(record.monthlyAverage);
|
||||
const balance = maybeNumber(average.balance) ?? 0;
|
||||
return `Prognose ${projectionCount} Monate, durchschnittlicher Saldo ${formatEuro(balance)}`;
|
||||
}
|
||||
|
||||
return "Werkzeug ausgeführt";
|
||||
}
|
||||
|
||||
export function buildToolTraceFromSteps(steps: unknown[]): ToolTrace[] {
|
||||
const trace: ToolTrace[] = [];
|
||||
for (const step of steps) {
|
||||
const stepRecord = unknownRecord(step);
|
||||
const toolResults = Array.isArray(stepRecord.toolResults) ? stepRecord.toolResults : [];
|
||||
for (const result of toolResults) {
|
||||
const resultRecord = unknownRecord(result);
|
||||
const name = maybeString(resultRecord.toolName);
|
||||
if (!name) continue;
|
||||
trace.push({
|
||||
name,
|
||||
inputSummary: summarizeToolInput(resultRecord.input),
|
||||
resultSummary: summarizeToolOutput(name, resultRecord.output),
|
||||
});
|
||||
}
|
||||
}
|
||||
return trace;
|
||||
}
|
||||
|
||||
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."),
|
||||
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."),
|
||||
});
|
||||
|
||||
const summaryToolInputSchema = transactionToolInputSchema.omit({ limit: true });
|
||||
|
||||
const forecastToolInputSchema = z.object({
|
||||
from: z.string().optional().describe("Optionales Startdatum der historischen Basis im Format YYYY-MM-DD."),
|
||||
to: z.string().optional().describe("Optionales Enddatum der historischen Basis im Format YYYY-MM-DD."),
|
||||
accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."),
|
||||
horizonMonths: z.number().int().min(1).max(3).optional().describe("Anzahl der zu prognostizierenden Monate, 1 bis 3."),
|
||||
});
|
||||
|
||||
export const ask = action({
|
||||
args: {
|
||||
messages: v.array(chatMessageValidator),
|
||||
@@ -325,6 +831,7 @@ export const ask = action({
|
||||
expenses: v.number(),
|
||||
balance: v.number(),
|
||||
}),
|
||||
toolTrace: v.array(toolTraceValidator),
|
||||
}),
|
||||
handler: async (ctx, args): Promise<ChatAskResult> => {
|
||||
if (args.messages.length === 0) {
|
||||
@@ -338,26 +845,65 @@ export const ask = action({
|
||||
}
|
||||
|
||||
await requireUserId(ctx);
|
||||
|
||||
const context: ChatPromptContext = await ctx.runQuery(internal.savingsChat.getPromptContext, {
|
||||
const scope: AgentToolScope = {
|
||||
from: args.from,
|
||||
to: args.to,
|
||||
accountId: args.accountId,
|
||||
basis: args.basis,
|
||||
};
|
||||
|
||||
const selectedSummary: {
|
||||
totalCount: number;
|
||||
totals: { income: number; expenses: number; balance: number; transactionCount: number };
|
||||
accountName?: string;
|
||||
} = await ctx.runQuery(internal.savingsChat.getTransactionsTool, {
|
||||
scope,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const lastMessages = args.messages
|
||||
.map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content }))
|
||||
.slice(-MAX_CONVERSATION_MESSAGES);
|
||||
|
||||
const prompt = buildPrompt(context, lastMessages);
|
||||
if (prompt.length > MAX_PROMPT_CHARACTERS) {
|
||||
throw new Error(
|
||||
"Der ausgewählte Zeitraum enthält zu viele Umsatzdetails für eine vollständige KI-Anfrage. Bitte Zeitraum oder Konto eingrenzen.",
|
||||
);
|
||||
}
|
||||
const system = buildSystemPrompt({
|
||||
from: args.from,
|
||||
to: args.to,
|
||||
basis: args.basis,
|
||||
accountName: selectedSummary.accountName,
|
||||
});
|
||||
|
||||
const system = buildSystemPrompt(context);
|
||||
const savingsTools = {
|
||||
get_transactions: tool({
|
||||
description:
|
||||
"Ruft passende Umsätze read-only ab. Nutze dieses Tool für Detailfragen, Suche nach Gegenparteien/Beschreibungen oder Belege einzelner Aussagen. Es liefert exakte Summen und nur begrenzte, sanitizte Zeilen.",
|
||||
inputSchema: transactionToolInputSchema,
|
||||
execute: async (input) =>
|
||||
await ctx.runQuery(internal.savingsChat.getTransactionsTool, {
|
||||
scope,
|
||||
...input,
|
||||
}),
|
||||
}),
|
||||
summarize_spending: tool({
|
||||
description:
|
||||
"Berechnet read-only exakte Summen, Monatsverläufe, Kategorien sowie fixe und variable Ausgaben für den ausgewählten oder angegebenen Zeitraum.",
|
||||
inputSchema: summaryToolInputSchema,
|
||||
execute: async (input) =>
|
||||
await ctx.runQuery(internal.savingsChat.summarizeSpendingTool, {
|
||||
scope,
|
||||
...input,
|
||||
}),
|
||||
}),
|
||||
forecast_cashflow: tool({
|
||||
description:
|
||||
"Erstellt eine deterministische Cashflow-Prognose für 1 bis 3 kommende Monate aus vollständigen historischen Monaten. Nutze es für Sparrate, Monatsüberschuss und kurzfristige Prognosen.",
|
||||
inputSchema: forecastToolInputSchema,
|
||||
execute: async (input) =>
|
||||
await ctx.runQuery(internal.savingsChat.forecastCashflowTool, {
|
||||
scope,
|
||||
...input,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
const envModel = process.env.SAVINGS_CHAT_MODEL?.trim();
|
||||
const candidates = [
|
||||
@@ -373,17 +919,20 @@ export const ask = action({
|
||||
const result = await generateText({
|
||||
model: openai(modelName),
|
||||
system,
|
||||
prompt,
|
||||
messages: lastMessages,
|
||||
tools: savingsTools,
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
return {
|
||||
model: modelName,
|
||||
answer: result.text,
|
||||
usedTransactions: context.totals.transactionCount,
|
||||
usedTransactions: selectedSummary.totals.transactionCount,
|
||||
usedBalance: {
|
||||
income: context.totals.income,
|
||||
expenses: context.totals.expenses,
|
||||
balance: context.totals.balance,
|
||||
income: selectedSummary.totals.income,
|
||||
expenses: selectedSummary.totals.expenses,
|
||||
balance: selectedSummary.totals.balance,
|
||||
},
|
||||
toolTrace: buildToolTraceFromSteps(result.steps),
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
Reference in New Issue
Block a user