Add financing data import and normalization flow
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
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.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [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
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
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
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
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.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -16,6 +16,14 @@ vi.mock("ai", async (importOriginal) => {
|
|||||||
tools: {
|
tools: {
|
||||||
get_transactions: { execute: (input: unknown) => Promise<unknown> };
|
get_transactions: { execute: (input: unknown) => Promise<unknown> };
|
||||||
summarize_spending: { execute: (input: unknown) => Promise<unknown> };
|
summarize_spending: { execute: (input: unknown) => Promise<unknown> };
|
||||||
|
get_accounts: { execute: (input: unknown) => Promise<unknown> };
|
||||||
|
get_categories: { execute: (input: unknown) => Promise<unknown> };
|
||||||
|
detect_recurring_transactions: { execute: (input: unknown) => Promise<unknown> };
|
||||||
|
find_anomalies: { execute: (input: unknown) => Promise<unknown> };
|
||||||
|
get_uncategorized_transactions: { execute: (input: unknown) => Promise<unknown> };
|
||||||
|
compare_periods: { execute: (input: unknown) => Promise<unknown> };
|
||||||
|
forecast_fixed_costs: { execute: (input: unknown) => Promise<unknown> };
|
||||||
|
explain_savings_rate: { execute: (input: unknown) => Promise<unknown> };
|
||||||
};
|
};
|
||||||
}) => {
|
}) => {
|
||||||
const transactionInput = { from: "2026-02-01", to: "2026-02-28", limit: 2 };
|
const transactionInput = { from: "2026-02-01", to: "2026-02-28", limit: 2 };
|
||||||
@@ -53,6 +61,150 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
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", () => {
|
describe("savingsChat.getContext", () => {
|
||||||
test("counts and sums every matching transaction before applying prompt limits", async () => {
|
test("counts and sums every matching transaction before applying prompt limits", async () => {
|
||||||
const t = convexTest(schema, modules);
|
const t = convexTest(schema, modules);
|
||||||
@@ -694,6 +846,14 @@ describe("savingsChat read-only agent tools", () => {
|
|||||||
get_transactions: expect.any(Object),
|
get_transactions: expect.any(Object),
|
||||||
summarize_spending: expect.any(Object),
|
summarize_spending: expect.any(Object),
|
||||||
forecast_cashflow: 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),
|
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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -314,6 +314,109 @@ const categoryBreakdownValidator = v.object({
|
|||||||
block: v.optional(v.union(v.literal("wiederkehrend"), v.literal("variabel"))),
|
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({
|
export const getContext = query({
|
||||||
args: contextArgsValidator,
|
args: contextArgsValidator,
|
||||||
returns: contextSummaryValidator,
|
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<typeof summarizeTransactions>,
|
||||||
|
) {
|
||||||
|
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<ToolTransactionContext, "categoryById">,
|
||||||
|
) {
|
||||||
|
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<string, number>;
|
||||||
|
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<string, number>(),
|
||||||
|
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<typeof pattern> => 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<typeof summarizeTransactions>) {
|
||||||
|
return new Map(summary.categoryBreakdown.map((entry) => [entry.name, entry.amount]));
|
||||||
|
}
|
||||||
|
|
||||||
function toDisplayContextLine(
|
function toDisplayContextLine(
|
||||||
tx: Doc<"transactions">,
|
tx: Doc<"transactions">,
|
||||||
categoryById: Map<Id<"categories">, string>,
|
categoryById: Map<Id<"categories">, 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<Id<"accounts">, 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<string, { count: number; amount: number }>();
|
||||||
|
for (const tx of uncategorized) {
|
||||||
|
const name = tx.counterparty?.trim() || tx.description.trim() || "Unbekannt";
|
||||||
|
const entry = counterpartyMap.get(name) ?? { count: 0, amount: 0 };
|
||||||
|
entry.count += 1;
|
||||||
|
entry.amount += tx.amount;
|
||||||
|
counterpartyMap.set(name, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, number> }
|
||||||
|
>();
|
||||||
|
|
||||||
|
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<string, number>() };
|
||||||
|
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<string, Map<string, number>>();
|
||||||
|
const amountByRecurringLabelMonth = new Map<string, Map<string, number>>();
|
||||||
|
|
||||||
|
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<string, number>();
|
||||||
|
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<string, number>();
|
||||||
|
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({
|
export const getPromptContext = internalQuery({
|
||||||
args: contextArgsValidator,
|
args: contextArgsValidator,
|
||||||
returns: v.object({
|
returns: v.object({
|
||||||
@@ -748,6 +1533,10 @@ function totalsFromOutput(output: Record<string, unknown>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countLabel(count: number, singular: string, plural: string) {
|
||||||
|
return `${count} ${count === 1 ? singular : plural}`;
|
||||||
|
}
|
||||||
|
|
||||||
function summarizeToolOutput(toolName: string, output: unknown) {
|
function summarizeToolOutput(toolName: string, output: unknown) {
|
||||||
const record = unknownRecord(output);
|
const record = unknownRecord(output);
|
||||||
if (toolName === "get_transactions") {
|
if (toolName === "get_transactions") {
|
||||||
@@ -773,6 +1562,49 @@ function summarizeToolOutput(toolName: string, output: unknown) {
|
|||||||
return `Prognose ${projectionCount} Monate, durchschnittlicher Saldo ${formatEuro(balance)}`;
|
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";
|
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."),
|
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({
|
export const ask = action({
|
||||||
args: {
|
args: {
|
||||||
messages: v.array(chatMessageValidator),
|
messages: v.array(chatMessageValidator),
|
||||||
@@ -903,6 +1779,86 @@ export const ask = action({
|
|||||||
...input,
|
...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();
|
const envModel = process.env.SAVINGS_CHAT_MODEL?.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user