Improve category filter alias resolution
This commit is contained in:
@@ -666,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);
|
||||
|
||||
@@ -1181,4 +1399,39 @@ describe("savingsChat read-only agent tools", () => {
|
||||
]);
|
||||
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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user