Compare commits
3 Commits
codex/savi
...
3541d00864
| Author | SHA1 | Date | |
|---|---|---|---|
| 3541d00864 | |||
| 0061d5ad82 | |||
| 3ceccafa57 |
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-1
|
||||
title: Add complete chat transaction context
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-15 13:52'
|
||||
updated_date: '2026-06-15 14:02'
|
||||
updated_date: '2026-06-15 19:54'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
@@ -61,3 +61,9 @@ Final review:
|
||||
- Prior blocker resolved: effective-basis loading now includes rows without effectiveMonth via bookingDate fallback queries.
|
||||
- Fresh full npm run lint still fails only on unrelated existing files; no SavingsChatPage issue remains.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-2
|
||||
title: Render category expense chart values as positive slices
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-15 14:34'
|
||||
updated_date: '2026-06-15 14:48'
|
||||
updated_date: '2026-06-15 19:54'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
@@ -60,3 +60,9 @@ Verification after layout update:
|
||||
|
||||
Note: npm run build still emits the existing Vite chunk-size warning for the main bundle.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-3
|
||||
title: Build read-only savings agent with tool calls
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-15 19:02'
|
||||
updated_date: '2026-06-15 19:11'
|
||||
updated_date: '2026-06-15 19:54'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
@@ -51,3 +51,9 @@ Verification:
|
||||
- PASS npm run build
|
||||
- BLOCKED npm run lint on pre-existing unrelated files: convex/bank/comdirectProvider.ts, convex/bank/config.ts, src/components/import/TanAwaitDialog.tsx, layout/ui fast-refresh exports, SettingsPage.tsx, generated eslint-disable warnings, and existing React compiler warnings. No TASK-3 touched file appears in the full-lint error list.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-4
|
||||
title: Add selection total to transactions
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-15 19:21'
|
||||
updated_date: '2026-06-15 19:24'
|
||||
updated_date: '2026-06-15 19:54'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
@@ -39,3 +39,9 @@ Add table selection controls on the transactions page so visible/filtered rows c
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added failing Vitest coverage for visible bulk selection and selected totals, then implemented transactionsSelection helpers and wired TransactionsPage header checkbox plus selected count/sum. Focused test and build pass. Global lint currently fails on pre-existing unrelated lint errors in Convex/generated/UI files.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-5
|
||||
title: Make transaction filters combinable
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-15 19:32'
|
||||
updated_date: '2026-06-15 19:34'
|
||||
updated_date: '2026-06-15 19:54'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
@@ -38,3 +38,9 @@ Fix the transactions page so global timeframe, account, and month-basis filters
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Root cause: TransactionsPage did not pass global from/to/account/monthBasis filters into api.transactions.list, and transactions.list ignored date filters on the search-index path. Added red Convex tests covering combined filters and effective-month basis, then added basis/date filtering in the query and wired global filters from the page. Focused tests and build pass.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-6
|
||||
title: Add transaction filter reset button
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-15 19:35'
|
||||
updated_date: '2026-06-15 19:37'
|
||||
updated_date: '2026-06-15 19:54'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
@@ -30,3 +30,9 @@ Add a reset button on the transactions page that clears the active filter combin
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added a transactions toolbar reset button using shared reset defaults. The button clears global filters (current month, all accounts, no categories, effective month basis), clears page filters (search, type, pending-only), and clears row selection. Added Vitest coverage for reset defaults. Focused tests and build pass.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed as Done after explicit user confirmation to mark all tasks done.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: TASK-7
|
||||
title: Add all read-only savings agent insight tools
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-15 19:39'
|
||||
updated_date: '2026-06-15 19:54'
|
||||
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 -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Closed as Done after explicit user confirmation to mark all tasks done. Implementation and verification notes are already recorded on the task.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
47
backlog/tasks/task-8 - Fix-savings-tool-category-matching.md
Normal file
47
backlog/tasks/task-8 - Fix-savings-tool-category-matching.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
id: TASK-8
|
||||
title: Fix savings tool category matching
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-15 20:10'
|
||||
updated_date: '2026-06-15 20:19'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 8000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Make Talk to Savings read-only tools resolve user/model category names like Supermärkte or Lebensmittel und Supermarkt to existing categories such as Lebensmittel & Supermarkt, and surface diagnostics instead of misleading silent zero results.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Category filter aliases resolve ampersand/und, casing, punctuation, umlauts, and simple German plural/flexion variants
|
||||
- [x] #2 All savings tools that accept categoryNames use shared resolution diagnostics
|
||||
- [x] #3 Unknown or ambiguous category filters return diagnostics and compact trace summaries instead of silently confident zero results
|
||||
- [x] #4 Regression tests cover Lebensmittel und Supermarkt, Supermärkte, unknown diagnostics, and trace summaries
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add failing Convex regression tests for tolerant categoryNames and diagnostics
|
||||
2. Implement shared category-name normalization and resolver in convex/savingsChat.ts
|
||||
3. Thread diagnostics through every categoryNames-aware tool output and compact traces
|
||||
4. Update tool/prompt guidance to avoid confident zero answers on unresolved filters
|
||||
5. Run focused Vitest, targeted ESLint, and production build
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented shared tolerant category resolver for savings tools and added diagnostics to categoryNames-aware outputs.
|
||||
Verification passed: npx vitest convex/savingsChat.test.ts --run (20 tests), npx eslint convex/savingsChat.ts convex/savingsChat.test.ts, npm run build (with existing Vite chunk-size warning).
|
||||
|
||||
Added an additional regression test to preserve the virtual Ohne Kategorie filter while moving categoryNames to resolver-based category IDs. Re-ran focused Vitest, targeted ESLint, and build successfully after this adjustment.
|
||||
|
||||
Addressed QA review findings: added ASCII transliteration coverage for Supermaerkte, explicit ambiguous category filter coverage, and multi-diagnostic compact trace summaries. Final verification passed: npx vitest convex/savingsChat.test.ts --run (22 tests), npx eslint convex/savingsChat.ts convex/savingsChat.test.ts, npm run build (with existing Vite chunk-size warning).
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -16,6 +16,14 @@ vi.mock("ai", async (importOriginal) => {
|
||||
tools: {
|
||||
get_transactions: { 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 };
|
||||
@@ -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);
|
||||
@@ -514,6 +666,224 @@ describe("savingsChat read-only agent tools", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("summarizeSpendingTool resolves supermarket category aliases", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Alias User",
|
||||
email: "alias@example.com",
|
||||
});
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const groceryId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Lebensmittel & Supermarkt",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#ef4444",
|
||||
sortOrder: 1,
|
||||
isSystem: true,
|
||||
});
|
||||
const householdId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Haushalt & Discounter",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#fb923c",
|
||||
sortOrder: 2,
|
||||
isSystem: true,
|
||||
});
|
||||
|
||||
for (const tx of [
|
||||
{ date: "2026-01-10", description: "REWE", amount: -100, categoryId: groceryId },
|
||||
{ date: "2026-02-10", description: "Kaufland", amount: -120, categoryId: groceryId },
|
||||
{ date: "2026-02-12", description: "Drogerie", amount: -40, categoryId: householdId },
|
||||
]) {
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
categoryId: tx.categoryId,
|
||||
bookingDate: tx.date,
|
||||
valueDate: tx.date,
|
||||
description: tx.description,
|
||||
amount: tx.amount,
|
||||
isPending: false,
|
||||
effectiveMonth: tx.date.slice(0, 7),
|
||||
});
|
||||
}
|
||||
|
||||
return { userId, accountId };
|
||||
});
|
||||
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|test-session`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
for (const categoryName of ["Lebensmittel und Supermarkt", "Supermärkte", "Supermaerkte"]) {
|
||||
const result = await asUser.query(internal.savingsChat.summarizeSpendingTool, {
|
||||
scope: {
|
||||
from: "2026-01-01",
|
||||
to: "2026-02-28",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
},
|
||||
categoryNames: [categoryName],
|
||||
});
|
||||
|
||||
expect(result.totals).toEqual({
|
||||
transactionCount: 2,
|
||||
income: 0,
|
||||
expenses: -220,
|
||||
balance: -220,
|
||||
});
|
||||
expect(result.categoryBreakdown.map((entry) => [entry.name, entry.amount])).toEqual([
|
||||
["Lebensmittel & Supermarkt", -220],
|
||||
]);
|
||||
expect(result.categoryFilter?.diagnostics).toEqual([
|
||||
{
|
||||
requested: categoryName,
|
||||
status: "resolved",
|
||||
matchedName: "Lebensmittel & Supermarkt",
|
||||
suggestions: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("getCategoriesTool reports unresolved category filters with suggestions", async () => {
|
||||
const { asUser, seeded } = await seedSavingsInsightFixture();
|
||||
|
||||
const result = await asUser.query(internal.savingsChat.getCategoriesTool, {
|
||||
scope: {
|
||||
from: "2026-01-01",
|
||||
to: "2026-03-31",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
},
|
||||
categoryNames: ["Lebensmittel Urlaub"],
|
||||
});
|
||||
|
||||
expect(result.categories).toEqual([]);
|
||||
expect(result.categoryFilter?.diagnostics).toEqual([
|
||||
{
|
||||
requested: "Lebensmittel Urlaub",
|
||||
status: "unresolved",
|
||||
suggestions: ["Lebensmittel"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("getCategoriesTool reports ambiguous category filters without applying them", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Ambiguous User",
|
||||
email: "ambiguous@example.com",
|
||||
});
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const onlineShoppingId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Shopping & Online",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#9333ea",
|
||||
sortOrder: 1,
|
||||
isSystem: true,
|
||||
});
|
||||
const clothesShoppingId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Shopping & Kleidung",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#db2777",
|
||||
sortOrder: 2,
|
||||
isSystem: true,
|
||||
});
|
||||
|
||||
for (const tx of [
|
||||
{ date: "2026-02-01", description: "Online Shop", amount: -50, categoryId: onlineShoppingId },
|
||||
{ date: "2026-02-02", description: "Schuhe", amount: -80, categoryId: clothesShoppingId },
|
||||
]) {
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
categoryId: tx.categoryId,
|
||||
bookingDate: tx.date,
|
||||
valueDate: tx.date,
|
||||
description: tx.description,
|
||||
amount: tx.amount,
|
||||
isPending: false,
|
||||
effectiveMonth: tx.date.slice(0, 7),
|
||||
});
|
||||
}
|
||||
|
||||
return { userId, accountId };
|
||||
});
|
||||
|
||||
const result = await t.withIdentity({
|
||||
subject: `${seeded.userId}|test-session`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
}).query(internal.savingsChat.getCategoriesTool, {
|
||||
scope: {
|
||||
from: "2026-02-01",
|
||||
to: "2026-02-28",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
},
|
||||
categoryNames: ["Shopping"],
|
||||
});
|
||||
|
||||
expect(result.categories).toEqual([]);
|
||||
expect(result.categoryFilter?.diagnostics).toEqual([
|
||||
{
|
||||
requested: "Shopping",
|
||||
status: "ambiguous",
|
||||
suggestions: ["Shopping & Kleidung", "Shopping & Online"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("summarizeSpendingTool preserves the virtual Ohne Kategorie filter", async () => {
|
||||
const { asUser, seeded } = await seedSavingsInsightFixture();
|
||||
|
||||
const result = await asUser.query(internal.savingsChat.summarizeSpendingTool, {
|
||||
scope: {
|
||||
from: "2026-02-01",
|
||||
to: "2026-02-28",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
},
|
||||
categoryNames: ["Ohne Kategorie"],
|
||||
});
|
||||
|
||||
expect(result.totals).toEqual({ transactionCount: 1, income: 0, expenses: -40, balance: -40 });
|
||||
expect(result.categoryBreakdown).toEqual([{ name: "Ohne Kategorie", amount: -40 }]);
|
||||
expect(result.categoryFilter?.diagnostics).toEqual([
|
||||
{
|
||||
requested: "Ohne Kategorie",
|
||||
status: "resolved",
|
||||
matchedName: "Ohne Kategorie",
|
||||
suggestions: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("forecastCashflowTool excludes partial current month from the baseline", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
@@ -694,6 +1064,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 +1089,349 @@ 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/);
|
||||
});
|
||||
|
||||
test("buildToolTraceFromSteps surfaces category filter diagnostics", () => {
|
||||
const trace = buildToolTraceFromSteps([
|
||||
{
|
||||
toolResults: [
|
||||
{
|
||||
toolName: "summarize_spending",
|
||||
input: { categoryNames: ["Lebensmittel Urlaub"] },
|
||||
output: {
|
||||
totals: { income: 0, expenses: 0, balance: 0, transactionCount: 0 },
|
||||
categoryBreakdown: [],
|
||||
categoryFilter: {
|
||||
diagnostics: [
|
||||
{
|
||||
requested: "Lebensmittel Urlaub",
|
||||
status: "unresolved",
|
||||
suggestions: ["Lebensmittel & Supermarkt"],
|
||||
},
|
||||
{
|
||||
requested: "Shopping",
|
||||
status: "ambiguous",
|
||||
suggestions: ["Shopping & Kleidung", "Shopping & Online"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(trace[0].resultSummary).toBe(
|
||||
"0 Umsätze, Saldo 0.00€, 0 Kategorien, Kategorie-Filter: Lebensmittel Urlaub unklar (Vorschlag: Lebensmittel & Supermarkt); Shopping mehrdeutig (Vorschläge: Shopping & Kleidung, Shopping & Online)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user