Improve category filter alias resolution

This commit is contained in:
2026-06-16 09:44:37 +02:00
parent 0061d5ad82
commit 3541d00864
3 changed files with 534 additions and 24 deletions

View File

@@ -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)",
);
});
});