diff --git a/backlog/tasks/task-3 - Build-read-only-savings-agent-with-tool-calls.md b/backlog/tasks/task-3 - Build-read-only-savings-agent-with-tool-calls.md new file mode 100644 index 0000000..6e1692f --- /dev/null +++ b/backlog/tasks/task-3 - Build-read-only-savings-agent-with-tool-calls.md @@ -0,0 +1,53 @@ +--- +id: TASK-3 +title: Build read-only savings agent with tool calls +status: In Progress +assignee: [] +created_date: '2026-06-15 19:02' +updated_date: '2026-06-15 19:11' +labels: [] +dependencies: [] +priority: high +ordinal: 3000 +--- + +## Description + + +Convert Talk to Savings from a prompt-packed chat into a read-only AI SDK tool-calling agent with deterministic transaction retrieval, spending summaries, forecasts, and compact frontend tool traces. + + +## Acceptance Criteria + +- [x] #1 Savings chat action uses AI SDK tool calls with a bounded multi-step loop and returns a compact tool trace +- [x] #2 Read-only transaction retrieval tool supports selected scope, bounded custom ranges, account/category/search filters, exact totals, sanitized rows, and hasMore +- [x] #3 Spending summary and cashflow forecast tools compute deterministic aggregates without model-invented math +- [x] #4 Frontend remains localStorage-backed, loads legacy chats, and renders compact tool traces under assistant answers +- [x] #5 Focused Convex tests, build, and targeted lint verification pass or known blockers are documented + + +## Implementation Plan + + +1. Write failing Convex tests for read-only tool queries and traces +2. Implement internal read-only savings agent tool queries +3. Switch savingsChat.ask to AI SDK tool calling with bounded multi-step loop +4. Update frontend chat messages and compact tool trace rendering +5. Run focused tests, build, targeted lint, and record verification notes + + +## Implementation Notes + + +Implementation complete pending user confirmation. + +Subagents: +- Frontend UX worker updated src/pages/SavingsChatPage.tsx for legacy-safe localStorage message normalization and compact assistant tool traces. +- QA explorer reviewed Convex test strategy and confirmed mock AI SDK approach for ask() coverage. + +Verification: +- PASS npx vitest convex/savingsChat.test.ts --run (8 tests) +- PASS npx eslint convex/savingsChat.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx +- PASS npm run build +- BLOCKED npm run lint on pre-existing unrelated files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, layout/ui fast-refresh exports, SettingsPage.tsx, generated eslint-disable warnings, and existing React compiler warnings. No TASK-3 touched file appears in the full-lint error list. + diff --git a/convex/savingsChat.test.ts b/convex/savingsChat.test.ts index b25be05..4725b63 100644 --- a/convex/savingsChat.test.ts +++ b/convex/savingsChat.test.ts @@ -1,14 +1,58 @@ /// import { convexTest } from "convex-test"; -import { describe, expect, test } from "vitest"; +import { generateText } from "ai"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { api, internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; +import { buildToolTraceFromSteps } from "./savingsChat"; import schema from "./schema"; +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateText: vi.fn(async (options: { + tools: { + get_transactions: { execute: (input: unknown) => Promise }; + summarize_spending: { execute: (input: unknown) => Promise }; + }; + }) => { + const transactionInput = { from: "2026-02-01", to: "2026-02-28", limit: 2 }; + const summaryInput = { from: "2026-02-01", to: "2026-02-28" }; + const transactionOutput = await options.tools.get_transactions.execute(transactionInput); + const summaryOutput = await options.tools.summarize_spending.execute(summaryInput); + + return { + text: "Agenten-Antwort", + steps: [ + { + toolResults: [ + { + toolName: "get_transactions", + input: transactionInput, + output: transactionOutput, + }, + { + toolName: "summarize_spending", + input: summaryInput, + output: summaryOutput, + }, + ], + }, + ], + }; + }), + }; +}); + const modules = import.meta.glob("./**/*.ts"); delete modules["./savingsChat.test.ts"]; +beforeEach(() => { + vi.clearAllMocks(); +}); + describe("savingsChat.getContext", () => { test("counts and sums every matching transaction before applying prompt limits", async () => { const t = convexTest(schema, modules); @@ -192,3 +236,479 @@ describe("savingsChat.getContext", () => { expect(context.transactionLines.join("\n")).not.toContain("Other account should not appear"); }); }); + +describe("savingsChat read-only agent tools", () => { + test("getTransactionsTool applies account scope, exact totals, limits, and sanitizes rows", async () => { + const t = convexTest(schema, modules); + + const seeded = await t.run(async (ctx) => { + const userId = await ctx.db.insert("users", { + name: "Tool User", + email: "tool@example.com", + }); + const categoryId = await ctx.db.insert("categories", { + userId, + name: "Lebensmittel", + kind: "ausgabe", + block: "variabel", + color: "#22c55e", + sortOrder: 1, + isSystem: false, + }); + const giroAccountId = await ctx.db.insert("accounts", { + userId, + name: "Girokonto", + type: "checking", + openingBalance: 0, + currency: "EUR", + isArchived: false, + }); + const savingsAccountId = await ctx.db.insert("accounts", { + userId, + name: "Tagesgeld", + type: "savings", + openingBalance: 0, + currency: "EUR", + isArchived: false, + }); + + await ctx.db.insert("transactions", { + userId, + accountId: giroAccountId, + categoryId, + bookingDate: "2026-01-05", + valueDate: "2026-01-05", + description: "Supermarkt", + counterparty: "Markt GmbH", + amount: -100, + isPending: false, + rawText: "RAW BANK PAYLOAD", + notes: "private note", + dedupHash: "secret-hash", + externalRef: "external-ref", + effectiveMonth: "2026-01", + }); + await ctx.db.insert("transactions", { + userId, + accountId: giroAccountId, + bookingDate: "2026-01-07", + valueDate: "2026-01-07", + description: "Baecker", + amount: -15, + isPending: false, + effectiveMonth: "2026-01", + }); + await ctx.db.insert("transactions", { + userId, + accountId: giroAccountId, + bookingDate: "2026-01-03", + valueDate: "2026-01-03", + description: "Kaffee", + amount: -5, + isPending: false, + effectiveMonth: "2026-01", + }); + await ctx.db.insert("transactions", { + userId, + accountId: savingsAccountId, + bookingDate: "2026-01-08", + valueDate: "2026-01-08", + description: "Other account", + amount: 999, + isPending: false, + effectiveMonth: "2026-01", + }); + + return { userId, giroAccountId }; + }); + + const asUser = t.withIdentity({ + subject: `${seeded.userId}|test-session`, + tokenIdentifier: `test:${seeded.userId}`, + }); + + const result = await asUser.query(internal.savingsChat.getTransactionsTool, { + scope: { + from: "2026-01-01", + to: "2026-01-31", + accountId: seeded.giroAccountId as Id<"accounts">, + basis: "booking", + }, + limit: 2, + }); + + expect(result.totalCount).toBe(3); + expect(result.hasMore).toBe(true); + expect(result.rows).toHaveLength(2); + expect(result.totals).toEqual({ + transactionCount: 3, + income: 0, + expenses: -120, + balance: -120, + }); + expect(result.rows[0].accountName).toBe("Girokonto"); + expect(result.rows.find((row) => row.description === "Supermarkt")?.categoryName).toBe( + "Lebensmittel", + ); + expect(Object.keys(result.rows[0])).not.toContain("rawText"); + expect(Object.keys(result.rows[0])).not.toContain("notes"); + expect(Object.keys(result.rows[0])).not.toContain("dedupHash"); + expect(Object.keys(result.rows[0])).not.toContain("externalRef"); + expect(Object.keys(result.rows[0])).not.toContain("userId"); + }); + + test("getTransactionsTool includes legacy effective-month rows via booking fallback", async () => { + const t = convexTest(schema, modules); + + const seeded = await t.run(async (ctx) => { + const userId = await ctx.db.insert("users", { + name: "Legacy User", + email: "legacy@example.com", + }); + const accountId = await ctx.db.insert("accounts", { + userId, + name: "Girokonto", + type: "checking", + openingBalance: 0, + currency: "EUR", + isArchived: false, + }); + await ctx.db.insert("transactions", { + userId, + accountId, + bookingDate: "2026-03-12", + valueDate: "2026-03-12", + description: "Legacy no effective month", + amount: -30, + isPending: false, + }); + await ctx.db.insert("transactions", { + userId, + accountId, + bookingDate: "2026-03-20", + valueDate: "2026-03-20", + description: "Modern effective month", + amount: -20, + isPending: false, + effectiveMonth: "2026-03", + }); + return { userId, accountId }; + }); + + const asUser = t.withIdentity({ + subject: `${seeded.userId}|test-session`, + tokenIdentifier: `test:${seeded.userId}`, + }); + + const result = await asUser.query(internal.savingsChat.getTransactionsTool, { + scope: { + from: "2026-03-01", + to: "2026-03-31", + accountId: seeded.accountId as Id<"accounts">, + basis: "effective", + }, + limit: 10, + }); + + expect(result.totalCount).toBe(2); + expect(result.totals.expenses).toBe(-50); + expect(result.rows.map((row) => row.description)).toContain("Legacy no effective month"); + }); + + test("summarizeSpendingTool computes exact monthly and category aggregates", async () => { + const t = convexTest(schema, modules); + + const seeded = await t.run(async (ctx) => { + const userId = await ctx.db.insert("users", { + name: "Summary User", + email: "summary@example.com", + }); + const accountId = await ctx.db.insert("accounts", { + userId, + name: "Girokonto", + type: "checking", + openingBalance: 0, + currency: "EUR", + isArchived: false, + }); + const rentId = await ctx.db.insert("categories", { + userId, + name: "Miete", + kind: "ausgabe", + block: "wiederkehrend", + color: "#64748b", + sortOrder: 1, + isSystem: false, + }); + const foodId = await ctx.db.insert("categories", { + userId, + name: "Lebensmittel", + kind: "ausgabe", + block: "variabel", + color: "#22c55e", + sortOrder: 2, + isSystem: false, + }); + const salaryId = await ctx.db.insert("categories", { + userId, + name: "Gehalt", + kind: "einnahme", + color: "#0ea5e9", + sortOrder: 3, + isSystem: false, + }); + + for (const tx of [ + { date: "2026-01-01", description: "Gehalt Januar", amount: 3000, categoryId: salaryId }, + { date: "2026-01-02", description: "Miete Januar", amount: -1000, categoryId: rentId }, + { date: "2026-01-10", description: "Supermarkt Januar", amount: -200, categoryId: foodId }, + { date: "2026-02-01", description: "Gehalt Februar", amount: 3000, categoryId: salaryId }, + { date: "2026-02-02", description: "Sonstiges Februar", amount: -50, categoryId: undefined }, + ]) { + await ctx.db.insert("transactions", { + userId, + accountId, + categoryId: tx.categoryId, + bookingDate: tx.date, + valueDate: tx.date, + description: tx.description, + amount: tx.amount, + isPending: false, + effectiveMonth: tx.date.slice(0, 7), + }); + } + + return { userId, accountId }; + }); + + const asUser = t.withIdentity({ + subject: `${seeded.userId}|test-session`, + tokenIdentifier: `test:${seeded.userId}`, + }); + + const result = await asUser.query(internal.savingsChat.summarizeSpendingTool, { + scope: { + from: "2026-01-01", + to: "2026-02-28", + accountId: seeded.accountId as Id<"accounts">, + basis: "effective", + }, + }); + + expect(result.totals).toEqual({ + transactionCount: 5, + income: 6000, + expenses: -1250, + balance: 4750, + }); + expect(result.fixedCosts).toBe(-1000); + expect(result.variableCosts).toBe(-200); + expect(result.monthlyTrend).toEqual([ + { month: "2026-01", income: 3000, expenses: -1200, balance: 1800 }, + { month: "2026-02", income: 3000, expenses: -50, balance: 2950 }, + ]); + expect(result.categoryBreakdown.map((entry) => [entry.name, entry.amount])).toEqual([ + ["Miete", -1000], + ["Lebensmittel", -200], + ["Ohne Kategorie", -50], + ]); + }); + + test("forecastCashflowTool excludes partial current month from the baseline", async () => { + const t = convexTest(schema, modules); + + const seeded = await t.run(async (ctx) => { + const userId = await ctx.db.insert("users", { + name: "Forecast User", + email: "forecast@example.com", + }); + const accountId = await ctx.db.insert("accounts", { + userId, + name: "Girokonto", + type: "checking", + openingBalance: 0, + currency: "EUR", + isArchived: false, + }); + + for (const tx of [ + { date: "2026-04-01", description: "Gehalt April", amount: 3000 }, + { date: "2026-04-10", description: "Kosten April", amount: -2000 }, + { date: "2026-05-01", description: "Gehalt Mai", amount: 3200 }, + { date: "2026-05-10", description: "Kosten Mai", amount: -2200 }, + { date: "2026-06-01", description: "Gehalt Juni", amount: 3000 }, + { date: "2026-06-10", description: "Teilkosten Juni", amount: -500 }, + ]) { + await ctx.db.insert("transactions", { + userId, + accountId, + bookingDate: tx.date, + valueDate: tx.date, + description: tx.description, + amount: tx.amount, + isPending: false, + effectiveMonth: tx.date.slice(0, 7), + }); + } + + return { userId, accountId }; + }); + + const asUser = t.withIdentity({ + subject: `${seeded.userId}|test-session`, + tokenIdentifier: `test:${seeded.userId}`, + }); + + const result = await asUser.query(internal.savingsChat.forecastCashflowTool, { + scope: { + from: "2026-04-01", + to: "2026-06-30", + accountId: seeded.accountId as Id<"accounts">, + basis: "effective", + }, + horizonMonths: 3, + asOf: "2026-06-15", + }); + + expect(result.baselineMonths).toEqual(["2026-04", "2026-05"]); + expect(result.excludedPartialMonth).toBe("2026-06"); + expect(result.monthlyAverage).toEqual({ income: 3100, expenses: -2100, balance: 1000 }); + expect(result.projection).toEqual([ + { month: "2026-07", income: 3100, expenses: -2100, balance: 1000 }, + { month: "2026-08", income: 3100, expenses: -2100, balance: 1000 }, + { month: "2026-09", income: 3100, expenses: -2100, balance: 1000 }, + ]); + }); + + test("buildToolTraceFromSteps returns compact summaries without raw tool output", () => { + const trace = buildToolTraceFromSteps([ + { + toolResults: [ + { + toolName: "get_transactions", + input: { from: "2026-01-01", to: "2026-01-31", limit: 50 }, + output: { + totalCount: 2, + hasMore: false, + totals: { income: 0, expenses: -115, balance: -115, transactionCount: 2 }, + rows: [ + { description: "Supermarkt", amount: -100 }, + { description: "Baecker", amount: -15 }, + ], + }, + }, + ], + }, + ]); + + expect(trace).toEqual([ + { + name: "get_transactions", + inputSummary: "2026-01-01 bis 2026-01-31, Limit 50", + resultSummary: "2 Umsätze, Saldo -115.00€, vollständig", + }, + ]); + }); + + test("ask returns compact tool traces from executed read-only tools", async () => { + const t = convexTest(schema, modules); + const previousKey = process.env.OPENAI_API_KEY; + const previousModel = process.env.SAVINGS_CHAT_MODEL; + + const seeded = await t.run(async (ctx) => { + const userId = await ctx.db.insert("users", { + name: "Ask User", + email: "ask@example.com", + }); + const accountId = await ctx.db.insert("accounts", { + userId, + name: "Girokonto", + type: "checking", + openingBalance: 0, + currency: "EUR", + isArchived: false, + }); + await ctx.db.insert("transactions", { + userId, + accountId, + bookingDate: "2026-02-01", + valueDate: "2026-02-01", + description: "Gehalt", + amount: 3000, + isPending: false, + effectiveMonth: "2026-02", + rawText: "RAW PAYLOAD SHOULD NOT LEAK", + }); + await ctx.db.insert("transactions", { + userId, + accountId, + bookingDate: "2026-02-10", + valueDate: "2026-02-10", + description: "Supermarkt", + amount: -120, + isPending: false, + effectiveMonth: "2026-02", + notes: "private note should not leak", + }); + return { userId, accountId }; + }); + + try { + process.env.OPENAI_API_KEY = "test-key"; + delete process.env.SAVINGS_CHAT_MODEL; + const asUser = t.withIdentity({ + subject: `${seeded.userId}|test-session`, + tokenIdentifier: `test:${seeded.userId}`, + }); + + const result = await asUser.action(api.savingsChat.ask, { + messages: [{ role: "user", content: "Wie sieht Februar aus?" }], + from: "2026-02-01", + to: "2026-02-28", + accountId: seeded.accountId as Id<"accounts">, + basis: "effective", + }); + + expect(result.answer).toBe("Agenten-Antwort"); + expect(result.model).toBe("gpt-5.4-mini"); + expect(result.usedTransactions).toBe(2); + expect(result.usedBalance).toEqual({ income: 3000, expenses: -120, balance: 2880 }); + expect(result.toolTrace).toEqual([ + { + name: "get_transactions", + inputSummary: "2026-02-01 bis 2026-02-28, Limit 2", + resultSummary: "2 Umsätze, Saldo 2880.00€, vollständig", + }, + { + name: "summarize_spending", + inputSummary: "2026-02-01 bis 2026-02-28", + resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien", + }, + ]); + expect(JSON.stringify(result.toolTrace)).not.toContain("RAW PAYLOAD"); + expect(JSON.stringify(result.toolTrace)).not.toContain("private note"); + + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + get_transactions: expect.any(Object), + summarize_spending: expect.any(Object), + forecast_cashflow: expect.any(Object), + }), + stopWhen: expect.any(Function), + }), + ); + } finally { + if (previousKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = previousKey; + } + if (previousModel === undefined) { + delete process.env.SAVINGS_CHAT_MODEL; + } else { + process.env.SAVINGS_CHAT_MODEL = previousModel; + } + } + }); +}); diff --git a/convex/savingsChat.ts b/convex/savingsChat.ts index 032230b..f372a11 100644 --- a/convex/savingsChat.ts +++ b/convex/savingsChat.ts @@ -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, Doc<"categories">>; + accountById: Map, 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, +) { + 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 { + 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, +) { + 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(); + 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, 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 { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +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) { + 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 => { 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; diff --git a/src/pages/SavingsChatPage.tsx b/src/pages/SavingsChatPage.tsx index c495af0..4d15ff1 100644 --- a/src/pages/SavingsChatPage.tsx +++ b/src/pages/SavingsChatPage.tsx @@ -11,7 +11,18 @@ import { Separator } from "@/components/ui/separator"; import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory"; import { toast } from "sonner"; -type ChatMessage = { role: "user" | "assistant"; content: string }; +type ToolTrace = { + name: string; + inputSummary: string; + resultSummary: string; +}; +type UserChatMessage = { role: "user"; content: string }; +type AssistantChatMessage = { + role: "assistant"; + content: string; + toolTrace?: ToolTrace[]; +}; +type ChatMessage = UserChatMessage | AssistantChatMessage; type ChatSession = { id: string; title: string; @@ -27,6 +38,77 @@ const initialAssistantMessage: ChatMessage = { }; const fallbackMessages = [initialAssistantMessage]; +function normalizeToolTrace(value: unknown): ToolTrace[] | undefined { + if (!Array.isArray(value)) return undefined; + const trace = value.flatMap((item) => { + if (!item || typeof item !== "object") return []; + const candidate = item as Record; + if ( + typeof candidate.name !== "string" || + typeof candidate.inputSummary !== "string" || + typeof candidate.resultSummary !== "string" + ) { + return []; + } + + return [ + { + name: candidate.name, + inputSummary: candidate.inputSummary, + resultSummary: candidate.resultSummary, + }, + ]; + }); + + return trace.length > 0 ? trace : undefined; +} + +function normalizeMessage(value: unknown): ChatMessage | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Record; + if (typeof candidate.content !== "string") return null; + if (candidate.role === "user") { + return { role: "user", content: candidate.content }; + } + if (candidate.role === "assistant") { + const toolTrace = normalizeToolTrace(candidate.toolTrace); + return toolTrace + ? { role: "assistant", content: candidate.content, toolTrace } + : { role: "assistant", content: candidate.content }; + } + + return null; +} + +function isChatMessage(value: ChatMessage | null): value is ChatMessage { + return value !== null; +} + +function normalizeSession(value: unknown): ChatSession | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Record; + if ( + typeof candidate.id !== "string" || + typeof candidate.title !== "string" || + typeof candidate.createdAt !== "number" || + typeof candidate.updatedAt !== "number" || + !Array.isArray(candidate.messages) + ) { + return null; + } + + const messages = candidate.messages.map(normalizeMessage); + if (!messages.every(isChatMessage)) return null; + + return { + id: candidate.id, + title: candidate.title, + createdAt: candidate.createdAt, + updatedAt: candidate.updatedAt, + messages, + }; +} + function createSession(): ChatSession { const now = Date.now(); const randomId = @@ -47,9 +129,11 @@ function loadSessions(): ChatSession[] { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return [createSession()]; - const parsed = JSON.parse(raw) as ChatSession[]; + const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()]; - return parsed; + const sessions = parsed.map(normalizeSession); + if (sessions.some((session) => session === null)) return [createSession()]; + return sessions as ChatSession[]; } catch { return [createSession()]; } @@ -156,16 +240,23 @@ export function SavingsChatPage() { try { const response = await ask({ - messages: typedNextMessages, + messages: typedNextMessages.map((message) => ({ + role: message.role, + content: message.content, + })), from, to, accountId, basis: monthBasis, - }); + }) as { answer: string; toolTrace?: unknown }; updateSession(submittedSessionId, [ ...typedNextMessages, - { role: "assistant", content: response.answer }, + { + role: "assistant", + content: response.answer, + toolTrace: normalizeToolTrace(response.toolTrace), + }, ]); } catch (error) { console.error(error); @@ -228,6 +319,21 @@ export function SavingsChatPage() { > {message.role} {message.content} + {message.role === "assistant" && message.toolTrace && message.toolTrace.length > 0 && ( + + + Verwendete Werkzeuge + + + {message.toolTrace.map((tool, toolIndex) => ( + + {tool.name} + {tool.resultSummary} + + ))} + + + )} ))} {isSubmitting && (
{message.role}
{message.content}
+ Verwendete Werkzeuge +
{tool.name}
{tool.resultSummary}