diff --git a/backlog/tasks/task-7 - Add-all-read-only-savings-agent-insight-tools.md b/backlog/tasks/task-7 - Add-all-read-only-savings-agent-insight-tools.md new file mode 100644 index 0000000..b227648 --- /dev/null +++ b/backlog/tasks/task-7 - Add-all-read-only-savings-agent-insight-tools.md @@ -0,0 +1,48 @@ +--- +id: TASK-7 +title: Add all read-only savings agent insight tools +status: In Progress +assignee: [] +created_date: '2026-06-15 19:39' +updated_date: '2026-06-15 19:49' +labels: [] +dependencies: [] +priority: high +ordinal: 7000 +--- + +## Description + + +Expand Talk to Savings with eight additional read-only AI SDK tools for accounts, categories, recurring transactions, anomalies, uncategorized transactions, period comparison, fixed-cost forecasting, and savings-rate explanations. + + +## Acceptance Criteria + +- [x] #1 Agent registers all eight new read-only tools and returns compact trace summaries without raw/private fields +- [x] #2 Metadata and data-quality tools return scoped, sanitized accounts, categories, and uncategorized transaction insight +- [x] #3 Period, savings-rate, recurring, fixed-cost forecast, and anomaly tools compute deterministic aggregates +- [x] #4 Convex tests cover each new tool and mocked ask() registration +- [x] #5 Focused tests, targeted lint, build, and full lint status are documented + + +## Implementation Plan + + +1. Add failing coverage for all eight read-only savings insight tools and ask() registry +2. Implement scoped/sanitized internal Convex queries and deterministic finance helpers +3. Register the tools in savingsChat.ask and add compact trace summaries +4. Verify focused tests, targeted lint, build, and document full-lint blockers + + +## Implementation Notes + + +Implemented eight new read-only tools in convex/savingsChat.ts: accounts, categories, recurring patterns, anomalies, uncategorized transactions, period comparison, fixed-cost forecast, and savings-rate explanation. +Verification: npx vitest convex/savingsChat.test.ts --run --reporter=dot passed (17 tests). +Verification: npx eslint convex/savingsChat.ts convex/savingsChat.test.ts src/pages/SavingsChatPage.tsx passed. +Verification: npm run build passed with existing Vite chunk-size warning. +Full npm run lint still fails on unrelated existing files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, several react-refresh only-export-components files, and src/pages/SettingsPage.tsx; warnings also remain in generated Convex files and React Hook Form/TanStack Table locations. + +Hardened compare_periods snapshots to omit category IDs while keeping existing summarize_spending output compatible. Re-ran focused Vitest, targeted ESLint, and build successfully after this change. + diff --git a/convex/savingsChat.test.ts b/convex/savingsChat.test.ts index 4725b63..d0fdfc8 100644 --- a/convex/savingsChat.test.ts +++ b/convex/savingsChat.test.ts @@ -16,6 +16,14 @@ vi.mock("ai", async (importOriginal) => { tools: { get_transactions: { execute: (input: unknown) => Promise }; summarize_spending: { execute: (input: unknown) => Promise }; + get_accounts: { execute: (input: unknown) => Promise }; + get_categories: { execute: (input: unknown) => Promise }; + detect_recurring_transactions: { execute: (input: unknown) => Promise }; + find_anomalies: { execute: (input: unknown) => Promise }; + get_uncategorized_transactions: { execute: (input: unknown) => Promise }; + compare_periods: { execute: (input: unknown) => Promise }; + forecast_fixed_costs: { execute: (input: unknown) => Promise }; + explain_savings_rate: { execute: (input: unknown) => Promise }; }; }) => { const transactionInput = { from: "2026-02-01", to: "2026-02-28", limit: 2 }; @@ -53,6 +61,150 @@ beforeEach(() => { vi.clearAllMocks(); }); +async function seedSavingsInsightFixture() { + const t = convexTest(schema, modules); + + const seeded = await t.run(async (ctx) => { + const userId = await ctx.db.insert("users", { + name: "Insight User", + email: "insight@example.com", + }); + const otherUserId = await ctx.db.insert("users", { + name: "Hidden User", + email: "hidden@example.com", + }); + const accountId = await ctx.db.insert("accounts", { + userId, + name: "Girokonto", + type: "checking", + openingBalance: 1000, + currency: "EUR", + isArchived: false, + }); + const oldAccountId = await ctx.db.insert("accounts", { + userId, + name: "Altes Konto", + type: "checking", + openingBalance: 50, + currency: "EUR", + isArchived: true, + }); + const hiddenAccountId = await ctx.db.insert("accounts", { + userId: otherUserId, + name: "Hidden", + type: "checking", + openingBalance: 0, + currency: "EUR", + isArchived: false, + }); + + const salaryId = await ctx.db.insert("categories", { + userId, + name: "Gehalt", + kind: "einnahme", + color: "#0ea5e9", + sortOrder: 1, + isSystem: false, + }); + const rentId = await ctx.db.insert("categories", { + userId, + name: "Miete", + kind: "ausgabe", + block: "wiederkehrend", + color: "#64748b", + sortOrder: 2, + isSystem: false, + }); + const subscriptionId = await ctx.db.insert("categories", { + userId, + name: "Abos", + kind: "ausgabe", + block: "wiederkehrend", + color: "#a855f7", + sortOrder: 3, + isSystem: false, + }); + const groceriesId = await ctx.db.insert("categories", { + userId, + name: "Lebensmittel", + kind: "ausgabe", + block: "variabel", + color: "#22c55e", + sortOrder: 4, + isSystem: false, + }); + + async function insertTx(input: { + date: string; + description: string; + amount: number; + categoryId?: Id<"categories">; + counterparty?: string; + account?: Id<"accounts">; + owner?: Id<"users">; + }) { + await ctx.db.insert("transactions", { + userId: input.owner ?? userId, + accountId: input.account ?? accountId, + categoryId: input.categoryId, + bookingDate: input.date, + valueDate: input.date, + description: input.description, + counterparty: input.counterparty, + amount: input.amount, + isPending: false, + effectiveMonth: input.date.slice(0, 7), + rawText: "RAW SHOULD NOT LEAK", + notes: "PRIVATE NOTE SHOULD NOT LEAK", + }); + } + + for (const month of ["2026-01", "2026-02", "2026-03"]) { + await insertTx({ + date: `${month}-01`, + description: "Gehalt", + counterparty: "Arbeitgeber", + amount: 3000, + categoryId: salaryId, + }); + await insertTx({ + date: `${month}-03`, + description: "Netflix", + counterparty: "Netflix", + amount: -15, + categoryId: subscriptionId, + }); + } + + await insertTx({ date: "2026-01-02", description: "Miete", counterparty: "Vermieter", amount: -1000, categoryId: rentId }); + await insertTx({ date: "2026-02-02", description: "Miete", counterparty: "Vermieter", amount: -1000, categoryId: rentId }); + await insertTx({ date: "2026-01-10", description: "Supermarkt", counterparty: "REWE", amount: -100, categoryId: groceriesId }); + await insertTx({ date: "2026-02-10", description: "Supermarkt", counterparty: "REWE", amount: -110, categoryId: groceriesId }); + await insertTx({ date: "2026-03-10", description: "Supermarkt", counterparty: "REWE", amount: -500, categoryId: groceriesId }); + await insertTx({ date: "2026-02-15", description: "Mystery Shop", counterparty: "Mystery GmbH", amount: -40 }); + await insertTx({ date: "2026-03-15", description: "Mystery Shop", counterparty: "Mystery GmbH", amount: -60 }); + await insertTx({ date: "2026-02-20", description: "Archived", amount: -20, account: oldAccountId }); + await insertTx({ + date: "2026-02-20", + description: "Hidden other user", + amount: 9999, + account: hiddenAccountId, + owner: otherUserId, + }); + + return { userId, accountId, oldAccountId, salaryId, rentId, subscriptionId, groceriesId }; + }); + + return { + t, + seeded, + asUser: t.withIdentity({ + subject: `${seeded.userId}|test-session`, + tokenIdentifier: `test:${seeded.userId}`, + }), + }; +} + describe("savingsChat.getContext", () => { test("counts and sums every matching transaction before applying prompt limits", async () => { const t = convexTest(schema, modules); @@ -694,6 +846,14 @@ describe("savingsChat read-only agent tools", () => { get_transactions: expect.any(Object), summarize_spending: expect.any(Object), forecast_cashflow: expect.any(Object), + get_accounts: expect.any(Object), + get_categories: expect.any(Object), + detect_recurring_transactions: expect.any(Object), + find_anomalies: expect.any(Object), + get_uncategorized_transactions: expect.any(Object), + compare_periods: expect.any(Object), + forecast_fixed_costs: expect.any(Object), + explain_savings_rate: expect.any(Object), }), stopWhen: expect.any(Function), }), @@ -711,4 +871,314 @@ describe("savingsChat read-only agent tools", () => { } } }); + + test("getAccountsTool returns sanitized account totals for the selected scope", async () => { + const t = convexTest(schema, modules); + + const seeded = await t.run(async (ctx) => { + const userId = await ctx.db.insert("users", { name: "Accounts User", email: "accounts@example.com" }); + const otherUserId = await ctx.db.insert("users", { name: "Other User", email: "other@example.com" }); + const giroAccountId = await ctx.db.insert("accounts", { + userId, + name: "Girokonto", + type: "checking", + iban: "DE123", + openingBalance: 1000, + currency: "EUR", + isArchived: false, + }); + const archiveAccountId = await ctx.db.insert("accounts", { + userId, + name: "Altes Konto", + type: "checking", + openingBalance: 50, + currency: "EUR", + isArchived: true, + }); + const otherAccountId = await ctx.db.insert("accounts", { + userId: otherUserId, + name: "Fremdes Konto", + type: "checking", + openingBalance: 0, + currency: "EUR", + isArchived: false, + }); + + await ctx.db.insert("transactions", { + userId, + accountId: giroAccountId, + bookingDate: "2026-02-01", + valueDate: "2026-02-01", + description: "Gehalt", + amount: 3000, + isPending: false, + effectiveMonth: "2026-02", + }); + await ctx.db.insert("transactions", { + userId, + accountId: giroAccountId, + bookingDate: "2026-02-05", + valueDate: "2026-02-05", + description: "Miete", + amount: -1000, + isPending: false, + effectiveMonth: "2026-02", + }); + await ctx.db.insert("transactions", { + userId, + accountId: archiveAccountId, + bookingDate: "2026-02-10", + valueDate: "2026-02-10", + description: "Altlast", + amount: -20, + isPending: false, + effectiveMonth: "2026-02", + }); + await ctx.db.insert("transactions", { + userId: otherUserId, + accountId: otherAccountId, + bookingDate: "2026-02-10", + valueDate: "2026-02-10", + description: "Should not appear", + amount: 9999, + isPending: false, + effectiveMonth: "2026-02", + }); + + return { userId }; + }); + + const result = await t.withIdentity({ + subject: `${seeded.userId}|test-session`, + tokenIdentifier: `test:${seeded.userId}`, + }).query(internal.savingsChat.getAccountsTool, { + scope: { from: "2026-02-01", to: "2026-02-28", basis: "effective" }, + includeArchived: true, + }); + + expect(result.accounts.map((account) => account.name)).toEqual(["Girokonto", "Altes Konto"]); + expect(result.accounts[0]).toEqual({ + name: "Girokonto", + type: "checking", + currency: "EUR", + isArchived: false, + openingBalance: 1000, + transactionCount: 2, + balance: 2000, + }); + expect(JSON.stringify(result)).not.toContain("DE123"); + expect(JSON.stringify(result)).not.toContain("Should not appear"); + }); + + test("getCategoriesTool returns scoped category totals and shares", async () => { + const { asUser, seeded } = await seedSavingsInsightFixture(); + + const result = await asUser.query(internal.savingsChat.getCategoriesTool, { + scope: { + from: "2026-01-01", + to: "2026-02-28", + accountId: seeded.accountId as Id<"accounts">, + basis: "effective", + }, + }); + + expect(result.categories.map((category) => [category.name, category.transactionCount, category.amount])).toEqual([ + ["Gehalt", 2, 6000], + ["Miete", 2, -2000], + ["Abos", 2, -30], + ["Lebensmittel", 2, -210], + ["Ohne Kategorie", 1, -40], + ]); + expect(result.categories.find((category) => category.name === "Miete")).toMatchObject({ + kind: "ausgabe", + block: "wiederkehrend", + shareOfExpenses: 0.877, + }); + expect(JSON.stringify(result)).not.toContain("Hidden other user"); + }); + + test("getUncategorizedTransactionsTool returns bounded sanitized uncategorized insight", async () => { + const { asUser, seeded } = await seedSavingsInsightFixture(); + + const result = await asUser.query(internal.savingsChat.getUncategorizedTransactionsTool, { + scope: { + from: "2026-02-01", + to: "2026-03-31", + accountId: seeded.accountId as Id<"accounts">, + basis: "effective", + }, + limit: 1, + }); + + expect(result.totalCount).toBe(2); + expect(result.hasMore).toBe(true); + expect(result.totals).toEqual({ transactionCount: 2, income: 0, expenses: -100, balance: -100 }); + expect(result.rows).toHaveLength(1); + expect(result.topCounterparties).toEqual([{ name: "Mystery GmbH", count: 2, amount: -100 }]); + expect(JSON.stringify(result)).not.toContain("RAW SHOULD NOT LEAK"); + expect(JSON.stringify(result)).not.toContain("PRIVATE NOTE SHOULD NOT LEAK"); + }); + + test("comparePeriodsTool computes totals and category deltas", async () => { + const { asUser, seeded } = await seedSavingsInsightFixture(); + + const result = await asUser.query(internal.savingsChat.comparePeriodsTool, { + scope: { + from: "2026-03-01", + to: "2026-03-31", + accountId: seeded.accountId as Id<"accounts">, + basis: "effective", + }, + compareFrom: "2026-02-01", + compareTo: "2026-02-28", + }); + + expect(result.current.totals).toEqual({ transactionCount: 4, income: 3000, expenses: -575, balance: 2425 }); + expect(result.previous.totals).toEqual({ transactionCount: 5, income: 3000, expenses: -1165, balance: 1835 }); + expect(result.deltas).toMatchObject({ income: 0, expenses: 590, balance: 590, fixedCosts: 1000, variableCosts: -390 }); + expect(result.categoryDeltas.find((entry) => entry.name === "Miete")).toMatchObject({ + currentAmount: 0, + previousAmount: -1000, + delta: 1000, + }); + expect(result.categoryDeltas.find((entry) => entry.name === "Lebensmittel")).toMatchObject({ + currentAmount: -500, + previousAmount: -110, + delta: -390, + }); + }); + + test("explainSavingsRateTool reports formula inputs and deterministic drivers", async () => { + const { asUser, seeded } = await seedSavingsInsightFixture(); + + const result = await asUser.query(internal.savingsChat.explainSavingsRateTool, { + scope: { + from: "2026-02-01", + to: "2026-02-28", + accountId: seeded.accountId as Id<"accounts">, + basis: "effective", + }, + }); + + expect(result).toMatchObject({ + income: 3000, + expenses: -1165, + savedAmount: 1835, + savingsRate: 0.612, + fixedCosts: -1015, + variableCosts: -110, + transactionCount: 5, + }); + expect(result.drivers.map((driver) => driver.name)).toEqual(["Miete", "Lebensmittel", "Ohne Kategorie"]); + expect(result.levers).toContainEqual({ + label: "Variable Ausgaben um 10% senken", + monthlyImpact: 11, + }); + }); + + test("detectRecurringTransactionsTool finds monthly patterns and skips one-offs", async () => { + const { asUser, seeded } = await seedSavingsInsightFixture(); + + const result = await asUser.query(internal.savingsChat.detectRecurringTransactionsTool, { + scope: { + from: "2026-01-01", + to: "2026-03-31", + accountId: seeded.accountId as Id<"accounts">, + basis: "effective", + }, + }); + + expect(result.patterns.map((pattern) => pattern.label)).toEqual(["Gehalt", "Netflix", "Miete"]); + expect(result.patterns.find((pattern) => pattern.label === "Miete")).toMatchObject({ + months: ["2026-01", "2026-02"], + occurrenceCount: 2, + averageAmount: -1000, + frequency: "monthly", + }); + expect(result.patterns.map((pattern) => pattern.label)).not.toContain("Mystery Shop"); + }); + + test("forecastFixedCostsTool forecasts recurring fixed costs for the requested horizon", async () => { + const { asUser, seeded } = await seedSavingsInsightFixture(); + + const result = await asUser.query(internal.savingsChat.forecastFixedCostsTool, { + scope: { + from: "2026-01-01", + to: "2026-02-28", + accountId: seeded.accountId as Id<"accounts">, + basis: "effective", + }, + horizonMonths: 2, + asOf: "2026-02-28", + }); + + expect(result.items.map((item) => [item.label, item.averageAmount])).toEqual([ + ["Miete", -1000], + ["Netflix", -15], + ]); + expect(result.forecast).toEqual([ + { month: "2026-03", totalFixedCosts: -1015 }, + { month: "2026-04", totalFixedCosts: -1015 }, + ]); + }); + + test("findAnomaliesTool reports amount spikes and missing recurring transactions", async () => { + const { asUser, seeded } = await seedSavingsInsightFixture(); + + const result = await asUser.query(internal.savingsChat.findAnomaliesTool, { + scope: { + from: "2026-01-01", + to: "2026-03-31", + accountId: seeded.accountId as Id<"accounts">, + basis: "effective", + }, + asOf: "2026-03-31", + }); + + expect(result.anomalies).toContainEqual({ + kind: "amount_spike", + label: "Lebensmittel", + month: "2026-03", + amount: -500, + expectedAmount: -105, + severity: "high", + }); + expect(result.anomalies).toContainEqual({ + kind: "missing_recurring", + label: "Miete", + month: "2026-03", + amount: 0, + expectedAmount: -1000, + severity: "medium", + }); + }); + + test("buildToolTraceFromSteps summarizes every insight tool without private payloads", () => { + const trace = buildToolTraceFromSteps([ + { + toolResults: [ + { toolName: "get_accounts", input: {}, output: { accounts: [{ name: "Girokonto" }] } }, + { toolName: "get_categories", input: {}, output: { categories: [{ name: "Miete" }] } }, + { toolName: "detect_recurring_transactions", input: {}, output: { patterns: [{ label: "Miete" }] } }, + { toolName: "find_anomalies", input: {}, output: { anomalies: [{ label: "Lebensmittel" }] } }, + { toolName: "get_uncategorized_transactions", input: { limit: 2 }, output: { totalCount: 3, hasMore: true } }, + { toolName: "compare_periods", input: {}, output: { deltas: { balance: 590 } } }, + { toolName: "forecast_fixed_costs", input: { horizonMonths: 2 }, output: { forecast: [{}, {}] } }, + { toolName: "explain_savings_rate", input: {}, output: { savingsRate: 0.612, savedAmount: 1835 } }, + ], + }, + ]); + + expect(trace.map((entry) => entry.resultSummary)).toEqual([ + "1 Konto ausgewertet", + "1 Kategorie ausgewertet", + "1 wiederkehrendes Muster erkannt", + "1 Auffälligkeit erkannt", + "3 unklassifizierte Umsätze, weitere vorhanden", + "Periodenvergleich, Saldo-Differenz 590.00€", + "Fixkosten-Prognose 2 Monate", + "Sparquote 61.2%, gespart 1835.00€", + ]); + expect(JSON.stringify(trace)).not.toMatch(/rawText|notes|dedupHash|externalRef|userId|_id|iban|externalId/); + }); }); diff --git a/convex/savingsChat.ts b/convex/savingsChat.ts index f372a11..9a2a0c5 100644 --- a/convex/savingsChat.ts +++ b/convex/savingsChat.ts @@ -314,6 +314,109 @@ const categoryBreakdownValidator = v.object({ block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))), }); +const safeCategoryBreakdownValidator = v.object({ + name: v.string(), + amount: v.number(), + block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))), +}); + +const accountInsightValidator = v.object({ + name: v.string(), + type: v.string(), + currency: v.string(), + isArchived: v.boolean(), + openingBalance: v.number(), + transactionCount: v.number(), + balance: v.number(), +}); + +const categoryInsightValidator = 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(), + transactionCount: v.number(), + amount: v.number(), + shareOfExpenses: v.optional(v.number()), +}); + +const recurringPatternValidator = v.object({ + label: v.string(), + counterparty: v.optional(v.string()), + categoryName: v.optional(v.string()), + months: v.array(v.string()), + occurrenceCount: v.number(), + averageAmount: v.number(), + minAmount: v.number(), + maxAmount: v.number(), + frequency: v.literal("monthly"), + lastDate: v.string(), + nextExpectedMonth: v.string(), +}); + +const anomalyValidator = v.object({ + kind: v.union(v.literal("amount_spike"), v.literal("missing_recurring")), + label: v.string(), + month: v.string(), + amount: v.number(), + expectedAmount: v.number(), + severity: v.union(v.literal("low"), v.literal("medium"), v.literal("high")), +}); + +const topCounterpartyValidator = v.object({ + name: v.string(), + count: v.number(), + amount: v.number(), +}); + +const summarySnapshotValidator = v.object({ + from: v.string(), + to: v.string(), + totals: totalsValidator, + fixedCosts: v.number(), + variableCosts: v.number(), + monthlyTrend: v.array(monthlyTrendValidator), + categoryBreakdown: v.array(safeCategoryBreakdownValidator), +}); + +const periodDeltasValidator = v.object({ + income: v.number(), + expenses: v.number(), + balance: v.number(), + fixedCosts: v.number(), + variableCosts: v.number(), +}); + +const categoryDeltaValidator = v.object({ + name: v.string(), + currentAmount: v.number(), + previousAmount: v.number(), + delta: v.number(), +}); + +const fixedCostItemValidator = v.object({ + label: v.string(), + averageAmount: v.number(), + occurrenceCount: v.number(), + months: v.array(v.string()), +}); + +const fixedCostForecastMonthValidator = v.object({ + month: v.string(), + totalFixedCosts: v.number(), +}); + +const savingsDriverValidator = v.object({ + name: v.string(), + amount: v.number(), + shareOfExpenses: v.number(), +}); + +const savingsLeverValidator = v.object({ + label: v.string(), + monthlyImpact: v.number(), +}); + export const getContext = query({ args: contextArgsValidator, returns: contextSummaryValidator, @@ -529,6 +632,155 @@ function summarizeTransactions(context: ToolTransactionContext) { }; } +function roundRatio(value: number) { + return Math.round(value * 1000) / 1000; +} + +function summarizeSnapshot( + context: ToolTransactionContext, + summary: ReturnType, +) { + return { + from: context.from, + to: context.to, + totals: summary.totals, + fixedCosts: summary.fixedCosts, + variableCosts: summary.variableCosts, + monthlyTrend: summary.monthlyTrend, + categoryBreakdown: summary.categoryBreakdown.map((entry) => ({ + name: entry.name, + amount: entry.amount, + ...(entry.block ? { block: entry.block } : {}), + })), + }; +} + +function normalizedText(value: string | undefined) { + return value?.trim().toLocaleLowerCase("de-DE") ?? ""; +} + +function recurringLabelForTransaction( + tx: Doc<"transactions">, + context: Pick, +) { + const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined; + return tx.description.trim() || tx.counterparty?.trim() || category?.name || "Unbekannt"; +} + +function dateForTransaction(tx: Doc<"transactions">) { + return tx.valueDate || tx.bookingDate || tx.effectiveMonth || "n/a"; +} + +function monthIndexesAreConsecutive(months: string[]) { + if (months.length < 2) return false; + for (let index = 1; index < months.length; index++) { + if (parseMonthIndex(months[index]) - parseMonthIndex(months[index - 1]) !== 1) { + return false; + } + } + return true; +} + +function amountSeriesIsStable(amounts: number[]) { + const average = amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length; + const maxDeviation = Math.max(...amounts.map((amount) => Math.abs(amount - average))); + return maxDeviation <= Math.max(Math.abs(average) * 0.2, 5); +} + +type RecurringDetectionOptions = { + beforeMonth?: string; + expensesOnly?: boolean; + fixedCategoriesOnly?: boolean; + includeUncategorized?: boolean; +}; + +function detectRecurringPatterns( + context: ToolTransactionContext, + options: RecurringDetectionOptions = {}, +) { + const groups = new Map< + string, + { + label: string; + counterparty?: string; + categoryName?: string; + amountsByMonth: Map; + lastDate: string; + firstCreationTime: number; + } + >(); + + for (const tx of context.transactions) { + if (tx.amount === 0) continue; + if (options.expensesOnly && tx.amount >= 0) continue; + if (!options.includeUncategorized && !tx.categoryId) continue; + + const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined; + if (options.fixedCategoriesOnly && category?.block !== "wiederkehrend") continue; + + const month = monthKeyFromBasis(tx, context.basis); + if (!month || (options.beforeMonth && month >= options.beforeMonth)) continue; + + const label = recurringLabelForTransaction(tx, context); + const key = [ + normalizedText(label), + normalizedText(tx.counterparty), + tx.categoryId ?? "none", + tx.amount > 0 ? "income" : "expense", + ].join("|"); + const entry = groups.get(key) ?? { + label, + counterparty: tx.counterparty, + categoryName: category?.name, + amountsByMonth: new Map(), + lastDate: dateForTransaction(tx), + firstCreationTime: tx._creationTime, + }; + entry.amountsByMonth.set(month, (entry.amountsByMonth.get(month) ?? 0) + tx.amount); + if (dateForTransaction(tx) > entry.lastDate) entry.lastDate = dateForTransaction(tx); + entry.firstCreationTime = Math.min(entry.firstCreationTime, tx._creationTime); + groups.set(key, entry); + } + + return [...groups.values()] + .map((entry) => { + const monthAmounts = [...entry.amountsByMonth.entries()].sort(([a], [b]) => + a.localeCompare(b), + ); + const months = monthAmounts.map(([month]) => month); + const amounts = monthAmounts.map(([, amount]) => amount); + if (months.length < 2 || !monthIndexesAreConsecutive(months) || !amountSeriesIsStable(amounts)) { + return null; + } + + const averageAmount = roundMoney(amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length); + return { + label: entry.label, + counterparty: entry.counterparty, + categoryName: entry.categoryName, + months, + occurrenceCount: months.length, + averageAmount, + minAmount: roundMoney(Math.min(...amounts)), + maxAmount: roundMoney(Math.max(...amounts)), + frequency: "monthly" as const, + lastDate: entry.lastDate, + nextExpectedMonth: addMonthsToMonthKey(months[months.length - 1], 1), + }; + }) + .filter((pattern): pattern is NonNullable => pattern !== null) + .sort( + (a, b) => + b.occurrenceCount - a.occurrenceCount || + a.label.localeCompare(b.label, "de-DE") || + a.lastDate.localeCompare(b.lastDate), + ); +} + +function buildCategoryAmountMap(summary: ReturnType) { + return new Map(summary.categoryBreakdown.map((entry) => [entry.name, entry.amount])); +} + function toDisplayContextLine( tx: Doc<"transactions">, categoryById: Map, string>, @@ -673,6 +925,539 @@ export const forecastCashflowTool = internalQuery({ }, }); +export const getAccountsTool = 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()), + includeArchived: v.optional(v.boolean()), + }, + returns: v.object({ + from: v.string(), + to: v.string(), + basis: v.union(v.literal("effective"), v.literal("booking")), + accounts: v.array(accountInsightValidator), + }), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + 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 transactions = await loadMatchingTransactions(ctx, userId, { + ...range, + accountId, + basis: args.scope.basis, + }); + const transactionsByAccount = new Map, Doc<"transactions">[]>(); + for (const tx of transactions) { + if (!tx.accountId) continue; + const list = transactionsByAccount.get(tx.accountId) ?? []; + list.push(tx); + transactionsByAccount.set(tx.accountId, list); + } + + const accounts = maps.accounts + .filter((account) => { + if (accountId && account._id !== accountId) return false; + return args.includeArchived === true || !account.isArchived; + }) + .sort((a, b) => Number(a.isArchived) - Number(b.isArchived) || a.name.localeCompare(b.name, "de-DE")) + .map((account) => { + const accountTransactions = transactionsByAccount.get(account._id) ?? []; + return { + name: account.name, + type: account.type, + currency: account.currency, + isArchived: account.isArchived, + openingBalance: roundMoney(account.openingBalance), + transactionCount: accountTransactions.length, + balance: calculateTotals(accountTransactions).balance, + }; + }); + + return { + from: range.from, + to: range.to, + basis: args.scope.basis, + accounts, + }; + }, +}); + +export const getCategoriesTool = 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()), + categories: v.array(categoryInsightValidator), + }), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const context = await buildToolTransactionContext(ctx, userId, args); + const totalExpenses = Math.abs(calculateTotals(context.transactions).expenses); + const sortOrderByCategory = new Map(context.categories.map((category) => [category._id, category.sortOrder])); + const categoryRows = new Map< + string, + { + categoryId?: Id<"categories">; + name: string; + kind: "einnahme" | "ausgabe"; + block?: "wiederkehrend" | "variabel"; + isSystem: boolean; + transactionCount: number; + amount: number; + } + >(); + + for (const tx of context.transactions) { + const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined; + const key = tx.categoryId ?? "none"; + const existing = categoryRows.get(key) ?? { + categoryId: tx.categoryId, + name: category?.name ?? "Ohne Kategorie", + kind: category?.kind ?? (tx.amount >= 0 ? "einnahme" : "ausgabe"), + block: category?.block, + isSystem: category?.isSystem ?? false, + transactionCount: 0, + amount: 0, + }; + existing.transactionCount += 1; + existing.amount += tx.amount; + if (!category && existing.amount < 0) existing.kind = "ausgabe"; + categoryRows.set(key, existing); + } + + const categories = [...categoryRows.values()] + .sort((a, b) => { + const aSort = a.categoryId ? sortOrderByCategory.get(a.categoryId) ?? 0 : Number.MAX_SAFE_INTEGER; + const bSort = b.categoryId ? sortOrderByCategory.get(b.categoryId) ?? 0 : Number.MAX_SAFE_INTEGER; + return aSort - bSort || a.name.localeCompare(b.name, "de-DE"); + }) + .map((entry) => ({ + name: entry.name, + kind: entry.kind, + ...(entry.block ? { block: entry.block } : {}), + isSystem: entry.isSystem, + transactionCount: entry.transactionCount, + amount: roundMoney(entry.amount), + ...(entry.amount < 0 && totalExpenses > 0 + ? { shareOfExpenses: roundRatio(Math.abs(entry.amount) / totalExpenses) } + : {}), + })); + + return { + from: context.from, + to: context.to, + basis: context.basis, + accountName: context.accountName, + categories, + }; + }, +}); + +export const getUncategorizedTransactionsTool = 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, + topCounterparties: v.array(topCounterpartyValidator), + rows: v.array(safeTransactionRowValidator), + }), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const context = await buildToolTransactionContext(ctx, userId, { + ...args, + categoryIds: undefined, + categoryNames: undefined, + }); + const limit = clampToolLimit(args.limit); + const uncategorized = context.transactions.filter((tx) => !tx.categoryId); + const counterpartyMap = new Map(); + 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); + } + + return { + from: context.from, + to: context.to, + basis: context.basis, + accountName: context.accountName, + totalCount: uncategorized.length, + hasMore: uncategorized.length > limit, + totals: calculateTotals(uncategorized), + topCounterparties: [...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), + rows: uncategorized.slice(0, limit).map((tx) => safeTransactionRow(tx, context)), + }; + }, +}); + +export const comparePeriodsTool = internalQuery({ + args: { + scope: toolScopeValidator, + from: v.optional(v.string()), + to: v.optional(v.string()), + compareFrom: v.string(), + compareTo: 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({ + basis: v.union(v.literal("effective"), v.literal("booking")), + accountName: v.optional(v.string()), + current: summarySnapshotValidator, + previous: summarySnapshotValidator, + deltas: periodDeltasValidator, + categoryDeltas: v.array(categoryDeltaValidator), + }), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const currentContext = await buildToolTransactionContext(ctx, userId, args); + const previousContext = await buildToolTransactionContext(ctx, userId, { + scope: args.scope, + from: args.compareFrom, + to: args.compareTo, + accountId: args.accountId, + accountName: args.accountName, + categoryIds: args.categoryIds, + categoryNames: args.categoryNames, + search: args.search, + type: args.type, + }); + const currentSummary = summarizeTransactions(currentContext); + const previousSummary = summarizeTransactions(previousContext); + const currentCategoryMap = buildCategoryAmountMap(currentSummary); + const previousCategoryMap = buildCategoryAmountMap(previousSummary); + const categoryNames = new Set([...currentCategoryMap.keys(), ...previousCategoryMap.keys()]); + + return { + basis: currentContext.basis, + accountName: currentContext.accountName, + current: summarizeSnapshot(currentContext, currentSummary), + previous: summarizeSnapshot(previousContext, previousSummary), + deltas: { + income: roundMoney(currentSummary.totals.income - previousSummary.totals.income), + expenses: roundMoney(currentSummary.totals.expenses - previousSummary.totals.expenses), + balance: roundMoney(currentSummary.totals.balance - previousSummary.totals.balance), + fixedCosts: roundMoney(currentSummary.fixedCosts - previousSummary.fixedCosts), + variableCosts: roundMoney(currentSummary.variableCosts - previousSummary.variableCosts), + }, + categoryDeltas: [...categoryNames] + .map((name) => { + const currentAmount = currentCategoryMap.get(name) ?? 0; + const previousAmount = previousCategoryMap.get(name) ?? 0; + return { + name, + currentAmount, + previousAmount, + delta: roundMoney(currentAmount - previousAmount), + }; + }) + .sort((a, b) => a.delta - b.delta || a.name.localeCompare(b.name, "de-DE")), + }; + }, +}); + +export const explainSavingsRateTool = 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(), + income: v.number(), + expenses: v.number(), + savedAmount: v.number(), + savingsRate: v.union(v.number(), v.null()), + fixedCosts: v.number(), + variableCosts: v.number(), + transactionCount: v.number(), + drivers: v.array(savingsDriverValidator), + levers: v.array(savingsLeverValidator), + }), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const context = await buildToolTransactionContext(ctx, userId, args); + const summary = summarizeTransactions(context); + const totalExpenses = Math.abs(summary.totals.expenses); + const drivers = summary.categoryBreakdown.slice(0, 3).map((entry) => ({ + name: entry.name, + amount: entry.amount, + shareOfExpenses: totalExpenses > 0 ? roundRatio(Math.abs(entry.amount) / totalExpenses) : 0, + })); + const levers = []; + if (summary.variableCosts < 0) { + levers.push({ + label: "Variable Ausgaben um 10% senken", + monthlyImpact: roundMoney(Math.abs(summary.variableCosts) * 0.1), + }); + } + if (summary.fixedCosts < 0) { + levers.push({ + label: "Fixkosten um 5% senken", + monthlyImpact: roundMoney(Math.abs(summary.fixedCosts) * 0.05), + }); + } + + return { + from: context.from, + to: context.to, + income: summary.totals.income, + expenses: summary.totals.expenses, + savedAmount: summary.totals.balance, + savingsRate: summary.totals.income > 0 ? roundRatio(summary.totals.balance / summary.totals.income) : null, + fixedCosts: summary.fixedCosts, + variableCosts: summary.variableCosts, + transactionCount: summary.totals.transactionCount, + drivers, + levers, + }; + }, +}); + +export const detectRecurringTransactionsTool = 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()), + patterns: v.array(recurringPatternValidator), + }), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const context = await buildToolTransactionContext(ctx, userId, args); + return { + from: context.from, + to: context.to, + basis: context.basis, + accountName: context.accountName, + patterns: detectRecurringPatterns(context), + }; + }, +}); + +export const forecastFixedCostsTool = 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()), + items: v.array(fixedCostItemValidator), + forecast: v.array(fixedCostForecastMonthValidator), + }), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const context = await buildToolTransactionContext(ctx, userId, args); + const fixedGroups = new Map< + string, + { label: string; amountsByMonth: Map } + >(); + + for (const tx of context.transactions) { + if (tx.amount >= 0 || !tx.categoryId) continue; + const category = context.categoryById.get(tx.categoryId); + if (category?.block !== "wiederkehrend") continue; + const month = monthKeyFromBasis(tx, context.basis); + if (!month) continue; + const label = recurringLabelForTransaction(tx, context); + const key = [normalizedText(label), tx.categoryId, normalizedText(tx.counterparty)].join("|"); + const entry = fixedGroups.get(key) ?? { label, amountsByMonth: new Map() }; + entry.amountsByMonth.set(month, (entry.amountsByMonth.get(month) ?? 0) + tx.amount); + fixedGroups.set(key, entry); + } + + const items = [...fixedGroups.values()] + .map((entry) => { + const monthAmounts = [...entry.amountsByMonth.entries()].sort(([a], [b]) => + a.localeCompare(b), + ); + const months = monthAmounts.map(([month]) => month); + const amounts = monthAmounts.map(([, amount]) => amount); + return { + label: entry.label, + averageAmount: roundMoney(amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length), + occurrenceCount: months.length, + months, + }; + }) + .filter((item) => item.months.length > 0) + .sort((a, b) => a.averageAmount - b.averageAmount || a.label.localeCompare(b.label, "de-DE")); + const horizonMonths = Math.max(1, Math.min(6, Math.floor(args.horizonMonths ?? 3))); + const asOfMonth = (args.asOf ?? context.to).slice(0, 7); + const totalFixedCosts = roundMoney(items.reduce((sum, item) => sum + item.averageAmount, 0)); + + return { + from: context.from, + to: context.to, + basis: context.basis, + accountName: context.accountName, + items, + forecast: Array.from({ length: horizonMonths }, (_, index) => ({ + month: addMonthsToMonthKey(asOfMonth, index + 1), + totalFixedCosts, + })), + }; + }, +}); + +export const findAnomaliesTool = 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()), + 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()), + anomalies: v.array(anomalyValidator), + }), + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const context = await buildToolTransactionContext(ctx, userId, args); + const targetMonth = (args.asOf ?? context.to).slice(0, 7); + const amountByCategoryMonth = new Map>(); + const amountByRecurringLabelMonth = new Map>(); + + for (const tx of context.transactions) { + const month = monthKeyFromBasis(tx, context.basis); + if (!month) continue; + + const category = tx.categoryId ? context.categoryById.get(tx.categoryId) : undefined; + const categoryName = category?.name ?? "Ohne Kategorie"; + const categoryMonthMap = amountByCategoryMonth.get(categoryName) ?? new Map(); + categoryMonthMap.set(month, (categoryMonthMap.get(month) ?? 0) + tx.amount); + amountByCategoryMonth.set(categoryName, categoryMonthMap); + + if (tx.categoryId) { + const label = recurringLabelForTransaction(tx, context); + const recurringMonthMap = amountByRecurringLabelMonth.get(label) ?? new Map(); + recurringMonthMap.set(month, (recurringMonthMap.get(month) ?? 0) + tx.amount); + amountByRecurringLabelMonth.set(label, recurringMonthMap); + } + } + + const anomalies: Array<{ + kind: "amount_spike" | "missing_recurring"; + label: string; + month: string; + amount: number; + expectedAmount: number; + severity: "low" | "medium" | "high"; + }> = []; + + for (const [label, amountsByMonth] of amountByCategoryMonth.entries()) { + const currentAmount = roundMoney(amountsByMonth.get(targetMonth) ?? 0); + if (currentAmount >= 0) continue; + const baseline = [...amountsByMonth.entries()] + .filter(([month, amount]) => month < targetMonth && amount < 0) + .map(([, amount]) => amount); + if (baseline.length < 2) continue; + const expectedAmount = roundMoney(baseline.reduce((sum, amount) => sum + amount, 0) / baseline.length); + const ratio = Math.abs(currentAmount) / Math.max(Math.abs(expectedAmount), 1); + if (ratio >= 2 && Math.abs(currentAmount - expectedAmount) >= 100) { + anomalies.push({ + kind: "amount_spike", + label, + month: targetMonth, + amount: currentAmount, + expectedAmount, + severity: ratio >= 3 ? "high" : "medium", + }); + } + } + + for (const pattern of detectRecurringPatterns(context, { beforeMonth: targetMonth })) { + if (pattern.nextExpectedMonth !== targetMonth) continue; + const actualAmount = roundMoney(amountByRecurringLabelMonth.get(pattern.label)?.get(targetMonth) ?? 0); + if (actualAmount === 0) { + anomalies.push({ + kind: "missing_recurring", + label: pattern.label, + month: targetMonth, + amount: 0, + expectedAmount: pattern.averageAmount, + severity: "medium", + }); + } + } + + return { + from: context.from, + to: context.to, + basis: context.basis, + accountName: context.accountName, + anomalies: anomalies.sort( + (a, b) => + (b.severity === "high" ? 2 : b.severity === "medium" ? 1 : 0) - + (a.severity === "high" ? 2 : a.severity === "medium" ? 1 : 0) || + a.label.localeCompare(b.label, "de-DE"), + ), + }; + }, +}); + export const getPromptContext = internalQuery({ args: contextArgsValidator, returns: v.object({ @@ -748,6 +1533,10 @@ function totalsFromOutput(output: Record) { }; } +function countLabel(count: number, singular: string, plural: string) { + return `${count} ${count === 1 ? singular : plural}`; +} + function summarizeToolOutput(toolName: string, output: unknown) { const record = unknownRecord(output); if (toolName === "get_transactions") { @@ -773,6 +1562,49 @@ function summarizeToolOutput(toolName: string, output: unknown) { return `Prognose ${projectionCount} Monate, durchschnittlicher Saldo ${formatEuro(balance)}`; } + if (toolName === "get_accounts") { + const count = Array.isArray(record.accounts) ? record.accounts.length : 0; + return `${countLabel(count, "Konto", "Konten")} ausgewertet`; + } + + if (toolName === "get_categories") { + const count = Array.isArray(record.categories) ? record.categories.length : 0; + return `${countLabel(count, "Kategorie", "Kategorien")} ausgewertet`; + } + + if (toolName === "detect_recurring_transactions") { + const count = Array.isArray(record.patterns) ? record.patterns.length : 0; + return `${count} ${count === 1 ? "wiederkehrendes Muster" : "wiederkehrende Muster"} erkannt`; + } + + if (toolName === "find_anomalies") { + const count = Array.isArray(record.anomalies) ? record.anomalies.length : 0; + return `${count} ${count === 1 ? "Auffälligkeit" : "Auffälligkeiten"} erkannt`; + } + + if (toolName === "get_uncategorized_transactions") { + const count = maybeNumber(record.totalCount) ?? 0; + const hasMore = record.hasMore === true; + return `${count} unklassifizierte Umsätze, ${hasMore ? "weitere vorhanden" : "vollständig"}`; + } + + if (toolName === "compare_periods") { + const deltas = unknownRecord(record.deltas); + const balance = maybeNumber(deltas.balance) ?? 0; + return `Periodenvergleich, Saldo-Differenz ${formatEuro(balance)}`; + } + + if (toolName === "forecast_fixed_costs") { + const count = Array.isArray(record.forecast) ? record.forecast.length : 0; + return `Fixkosten-Prognose ${count} Monate`; + } + + if (toolName === "explain_savings_rate") { + const savingsRate = maybeNumber(record.savingsRate) ?? 0; + const savedAmount = maybeNumber(record.savedAmount) ?? 0; + return `Sparquote ${(savingsRate * 100).toFixed(1)}%, gespart ${formatEuro(savedAmount)}`; + } + return "Werkzeug ausgeführt"; } @@ -814,6 +1646,50 @@ const forecastToolInputSchema = z.object({ horizonMonths: z.number().int().min(1).max(3).optional().describe("Anzahl der zu prognostizierenden Monate, 1 bis 3."), }); +const accountToolInputSchema = 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 nur ein Konto betrachtet werden soll."), + includeArchived: z.boolean().optional().describe("Archivierte Konten einbeziehen."), +}); + +const recurringToolInputSchema = summaryToolInputSchema; + +const anomalyToolInputSchema = 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."), + asOf: z.string().optional().describe("Stichtag für erwartete Muster im Format YYYY-MM-DD."), +}); + +const uncategorizedToolInputSchema = 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("Optionaler Suchtext für Beschreibung oder Gegenpartei."), + 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 comparePeriodsToolInputSchema = z.object({ + from: z.string().optional().describe("Startdatum des aktuellen Zeitraums im Format YYYY-MM-DD."), + to: z.string().optional().describe("Enddatum des aktuellen Zeitraums im Format YYYY-MM-DD."), + compareFrom: z.string().describe("Startdatum des Vergleichszeitraums im Format YYYY-MM-DD."), + compareTo: z.string().describe("Enddatum des Vergleichszeitraums im Format YYYY-MM-DD."), + accountName: z.string().optional().describe("Optionaler Kontoname, falls von der UI-Auswahl abweichend."), + categoryNames: z.array(z.string()).optional().describe("Optionale Kategorienamen für den Vergleich."), + search: z.string().optional().describe("Optionaler Suchtext für beide Zeiträume."), + type: z.enum(["income", "expense"]).optional().describe("Optional nur Einnahmen oder Ausgaben vergleichen."), +}); + +const fixedCostsForecastToolInputSchema = z.object({ + from: z.string().optional().describe("Optionales Startdatum der historischen Fixkostenbasis im Format YYYY-MM-DD."), + to: z.string().optional().describe("Optionales Enddatum der historischen Fixkostenbasis 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(6).optional().describe("Anzahl der zu prognostizierenden Monate, 1 bis 6."), + asOf: z.string().optional().describe("Stichtag für den Start der Prognose im Format YYYY-MM-DD."), +}); + export const ask = action({ args: { messages: v.array(chatMessageValidator), @@ -903,6 +1779,86 @@ export const ask = action({ ...input, }), }), + get_accounts: tool({ + description: + "Listet read-only Konten mit Typ, Währung, Archivstatus, Startsaldo, Umsatzanzahl und Zeitraumssaldo. Nutze es für Fragen nach Konten, Konto-Scope oder Datenabdeckung.", + inputSchema: accountToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.getAccountsTool, { + scope, + ...input, + }), + }), + get_categories: tool({ + description: + "Listet read-only Kategorien mit Art, Fix/Variabel-Block, Umsatzanzahl, Summe und Ausgabenanteil im Zeitraum. Nutze es für Kategorie- und Budgetstrukturfragen.", + inputSchema: summaryToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.getCategoriesTool, { + scope, + ...input, + }), + }), + detect_recurring_transactions: tool({ + description: + "Erkennt deterministisch monatlich wiederkehrende Muster nach Beschreibung, Gegenpartei, Kategorie und stabiler Betragshöhe. Nutze es für Miete, Gehalt, Abos und regelmäßige Abbuchungen.", + inputSchema: recurringToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.detectRecurringTransactionsTool, { + scope, + ...input, + }), + }), + find_anomalies: tool({ + description: + "Findet read-only auffällige Betragsausreißer und fehlende erwartete wiederkehrende Buchungen gegenüber historischen Mustern.", + inputSchema: anomalyToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.findAnomaliesTool, { + scope, + ...input, + }), + }), + get_uncategorized_transactions: tool({ + description: + "Ruft bounded und sanitizt unklassifizierte Umsätze mit Summen und Top-Gegenparteien ab. Nutze es für Datenqualität und Fragen nach fehlenden Kategorien.", + inputSchema: uncategorizedToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.getUncategorizedTransactionsTool, { + scope, + ...input, + }), + }), + compare_periods: tool({ + description: + "Vergleicht zwei Zeiträume deterministisch mit Totals, Monatsverlauf, Kategorie-Deltas und Fix/Variabel-Deltas.", + inputSchema: comparePeriodsToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.comparePeriodsTool, { + scope, + ...input, + }), + }), + forecast_fixed_costs: tool({ + description: + "Prognostiziert wiederkehrende Fixkosten für 1 bis 6 Monate aus Fixkosten-Kategorien und stabilen historischen Monatsmustern.", + inputSchema: fixedCostsForecastToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.forecastFixedCostsTool, { + scope, + ...input, + }), + }), + explain_savings_rate: tool({ + description: + "Berechnet Sparquote, gesparten Betrag, fixe und variable Kostenquote, Haupttreiber und konkrete Hebel aus exakten Aggregaten.", + inputSchema: summaryToolInputSchema, + execute: async (input) => + await ctx.runQuery(internal.savingsChat.explainSavingsRateTool, { + scope, + ...input, + }), + }), }; const envModel = process.env.SAVINGS_CHAT_MODEL?.trim();