Add savings chat analysis feature
This commit is contained in:
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -34,6 +34,7 @@ import type * as lib_helpers from "../lib/helpers.js";
|
||||
import type * as lib_month from "../lib/month.js";
|
||||
import type * as lib_seedCategories from "../lib/seedCategories.js";
|
||||
import type * as loans from "../loans.js";
|
||||
import type * as savingsChat from "../savingsChat.js";
|
||||
import type * as settings from "../settings.js";
|
||||
import type * as transactions from "../transactions.js";
|
||||
import type * as users from "../users.js";
|
||||
@@ -71,6 +72,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/month": typeof lib_month;
|
||||
"lib/seedCategories": typeof lib_seedCategories;
|
||||
loans: typeof loans;
|
||||
savingsChat: typeof savingsChat;
|
||||
settings: typeof settings;
|
||||
transactions: typeof transactions;
|
||||
users: typeof users;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import type { MutationCtx, QueryCtx } from "../_generated/server";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server";
|
||||
import type { Id } from "../_generated/dataModel";
|
||||
import { categorize, roundEur } from "./categorize";
|
||||
import { computeEffectiveMonth, resolveAssignedAndEffective } from "./month";
|
||||
import { computeDedupHash } from "./comdirectMap";
|
||||
|
||||
export async function requireUserId(ctx: QueryCtx | MutationCtx): Promise<Id<"users">> {
|
||||
export async function requireUserId(ctx: QueryCtx | MutationCtx | ActionCtx): Promise<Id<"users">> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Nicht angemeldet");
|
||||
return userId;
|
||||
|
||||
194
convex/savingsChat.test.ts
Normal file
194
convex/savingsChat.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { convexTest } from "convex-test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { api, internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
delete modules["./savingsChat.test.ts"];
|
||||
|
||||
describe("savingsChat.getContext", () => {
|
||||
test("counts and sums every matching transaction before applying prompt limits", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
});
|
||||
const giroAccountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const otherAccountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Tagesgeld",
|
||||
type: "savings",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
|
||||
const amounts: number[] = [];
|
||||
const months = ["2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06"];
|
||||
for (let index = 0; index < 450; index++) {
|
||||
const month = months[index % months.length];
|
||||
const day = String((index % 27) + 1).padStart(2, "0");
|
||||
const bookingDate = `${month}-${day}`;
|
||||
const amount = index % 3 === 0 ? 100 : -25;
|
||||
amounts.push(amount);
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
bookingDate,
|
||||
valueDate: bookingDate,
|
||||
description: `Giro transaction ${index}`,
|
||||
counterparty: "Counterparty",
|
||||
amount,
|
||||
isPending: false,
|
||||
effectiveMonth: index % 10 === 0 ? undefined : bookingDate.slice(0, 7),
|
||||
});
|
||||
}
|
||||
|
||||
for (let index = 0; index < 50; index++) {
|
||||
const bookingDate = `2026-06-${String((index % 27) + 1).padStart(2, "0")}`;
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: otherAccountId,
|
||||
bookingDate,
|
||||
valueDate: bookingDate,
|
||||
description: `Other account transaction ${index}`,
|
||||
amount: 999,
|
||||
isPending: false,
|
||||
effectiveMonth: bookingDate.slice(0, 7),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
giroAccountId,
|
||||
expectedIncome: amounts.filter((amount) => amount > 0).reduce((sum, amount) => sum + amount, 0),
|
||||
expectedExpenses: amounts.filter((amount) => amount < 0).reduce((sum, amount) => sum + amount, 0),
|
||||
expectedBalance: amounts.reduce((sum, amount) => sum + amount, 0),
|
||||
};
|
||||
});
|
||||
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|test-session`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const context = await asUser.query(api.savingsChat.getContext, {
|
||||
from: "2025-12-01",
|
||||
to: "2026-06-30",
|
||||
accountId: seeded.giroAccountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
});
|
||||
|
||||
expect(context.accountName).toBe("Girokonto");
|
||||
expect(context.isComplete).toBe(true);
|
||||
expect(context.totals.transactionCount).toBe(450);
|
||||
expect(context.totals.income).toBe(seeded.expectedIncome);
|
||||
expect(context.totals.expenses).toBe(seeded.expectedExpenses);
|
||||
expect(context.totals.balance).toBe(seeded.expectedBalance);
|
||||
});
|
||||
|
||||
test("builds complete prompt lines for every matching transaction", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Prompt User",
|
||||
email: "prompt@example.com",
|
||||
});
|
||||
const categoryId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Lebensmittel",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#22c55e",
|
||||
sortOrder: 1,
|
||||
isSystem: false,
|
||||
});
|
||||
const giroAccountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const otherAccountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Depot",
|
||||
type: "investment",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
categoryId,
|
||||
bookingDate: "2026-02-14",
|
||||
valueDate: "2026-02-14",
|
||||
description: "Supermarkt",
|
||||
counterparty: "Markt GmbH",
|
||||
amount: -42.5,
|
||||
isPending: false,
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
bookingDate: "2026-02-15",
|
||||
valueDate: "2026-02-15",
|
||||
description: "Gehalt",
|
||||
counterparty: "Arbeitgeber",
|
||||
amount: 2500,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-02",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: otherAccountId,
|
||||
bookingDate: "2026-02-16",
|
||||
valueDate: "2026-02-16",
|
||||
description: "Other account should not appear",
|
||||
amount: 999,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-02",
|
||||
});
|
||||
|
||||
return { userId, giroAccountId };
|
||||
});
|
||||
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|test-session`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const context = await asUser.query(internal.savingsChat.getPromptContext, {
|
||||
from: "2026-02-01",
|
||||
to: "2026-02-28",
|
||||
accountId: seeded.giroAccountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
});
|
||||
|
||||
expect(context.totals.transactionCount).toBe(2);
|
||||
expect(context.transactionLines).toHaveLength(2);
|
||||
expect(context.transactionLines.join("\n")).toContain(
|
||||
"2026-02-14 | Supermarkt (Markt GmbH) | -42.50€ | Lebensmittel | Girokonto",
|
||||
);
|
||||
expect(context.transactionLines.join("\n")).toContain(
|
||||
"2026-02-15 | Gehalt (Arbeitgeber) | 2500.00€ | Ohne Kategorie | Girokonto",
|
||||
);
|
||||
expect(context.transactionLines.join("\n")).not.toContain("Other account should not appear");
|
||||
});
|
||||
});
|
||||
399
convex/savingsChat.ts
Normal file
399
convex/savingsChat.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { action, internalQuery, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { generateText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { internal } from "./_generated/api";
|
||||
import { bookingMonth } from "./lib/month";
|
||||
import { requireUserId } from "./lib/helpers";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { QueryCtx } from "./_generated/server";
|
||||
|
||||
type ChatRole = "user" | "assistant";
|
||||
type ChatMessage = { role: ChatRole; content: string };
|
||||
|
||||
const chatMessageValidator = v.object({
|
||||
role: v.union(v.literal("user"), v.literal("assistant")),
|
||||
content: v.string(),
|
||||
});
|
||||
|
||||
const MAX_CONVERSATION_MESSAGES = 20;
|
||||
const MAX_PROMPT_CHARACTERS = 180_000;
|
||||
|
||||
type ChatContextArgs = {
|
||||
from: string;
|
||||
to: string;
|
||||
accountId?: Id<"accounts">;
|
||||
basis: "effective" | "booking";
|
||||
};
|
||||
type ChatContextSummary = {
|
||||
from: string;
|
||||
to: string;
|
||||
basis: "effective" | "booking";
|
||||
accountId?: Id<"accounts">;
|
||||
accountName?: string;
|
||||
totals: { transactionCount: number; income: number; expenses: number; balance: number };
|
||||
isComplete: true;
|
||||
};
|
||||
type ChatPromptContext = ChatContextSummary & {
|
||||
transactionLines: string[];
|
||||
};
|
||||
type ChatAskResult = {
|
||||
model: string;
|
||||
answer: string;
|
||||
usedTransactions: number;
|
||||
usedBalance: { income: number; expenses: number; balance: number };
|
||||
};
|
||||
|
||||
function formatEuro(value: number): string {
|
||||
return `${value.toFixed(2)}€`;
|
||||
}
|
||||
|
||||
function buildSystemPrompt(context: { from: string; to: string; basis: string; accountName?: string }) {
|
||||
return [
|
||||
"Du bist ein präziser Finanz-Chat-Assistent für Privatanwender.",
|
||||
"Nutze ausschließlich die gelieferten Umsätze als Kontext und beziehe dich nur auf die angegebenen Werte.",
|
||||
"Antworte auf Deutsch, kurz und handlungsorientiert.",
|
||||
`Zeitraum: ${context.from} bis ${context.to}.`,
|
||||
`Basis: ${context.basis}.`,
|
||||
context.accountName ? `Konto: ${context.accountName}.` : "Konto: Alle Konten.",
|
||||
"Wenn eine Aussage nur grob geschätzt werden kann, kennzeichne sie als Schätzung.",
|
||||
"Verwende keine Links, keine HTML-Tags und keine Emojis.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function buildPrompt(context: ChatPromptContext, conversation: ChatMessage[]) {
|
||||
return [
|
||||
"Kontext der Auswertung:",
|
||||
`Zeitraum: ${context.from} bis ${context.to}`,
|
||||
`Basis: ${context.basis}`,
|
||||
`Konto: ${context.accountName ?? "Alle Konten"}`,
|
||||
`Anzahl Umsätze: ${context.totals.transactionCount}`,
|
||||
`Einnahmen: ${formatEuro(context.totals.income)}`,
|
||||
`Ausgaben: ${formatEuro(context.totals.expenses)}`,
|
||||
`Saldo: ${formatEuro(context.totals.balance)}`,
|
||||
"",
|
||||
"Umsätze (neueste zuerst):",
|
||||
...(context.transactionLines.length > 0
|
||||
? context.transactionLines
|
||||
: ["Keine Umsätze im Zeitraum."]),
|
||||
"",
|
||||
"Gesprächsverlauf:",
|
||||
...conversation.map((message) => `${message.role}: ${message.content}`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function normalizeRole(role: ChatRole): "user" | "assistant" {
|
||||
return role;
|
||||
}
|
||||
|
||||
function sortTransactionsForContext(
|
||||
transactions: Doc<"transactions">[],
|
||||
basis: ChatContextArgs["basis"],
|
||||
) {
|
||||
return transactions.sort((a, b) => {
|
||||
const aMonth = basis === "effective" ? a.effectiveMonth ?? bookingMonth(a.bookingDate) ?? "" : "";
|
||||
const bMonth = basis === "effective" ? b.effectiveMonth ?? bookingMonth(b.bookingDate) ?? "" : "";
|
||||
const aDate = basis === "booking" ? a.bookingDate ?? "" : a.valueDate ?? a.bookingDate ?? "";
|
||||
const bDate = basis === "booking" ? b.bookingDate ?? "" : b.valueDate ?? b.bookingDate ?? "";
|
||||
const aKey = `${aMonth}|${aDate}|${a._creationTime}`;
|
||||
const bKey = `${bMonth}|${bDate}|${b._creationTime}`;
|
||||
return bKey.localeCompare(aKey);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMatchingTransactions(
|
||||
ctx: QueryCtx,
|
||||
userId: Id<"users">,
|
||||
args: ChatContextArgs,
|
||||
): Promise<Doc<"transactions">[]> {
|
||||
const monthFrom = args.from.slice(0, 7);
|
||||
const monthTo = args.to.slice(0, 7);
|
||||
const transactions: Doc<"transactions">[] = [];
|
||||
|
||||
if (args.basis === "effective") {
|
||||
if (args.accountId) {
|
||||
const accountId = args.accountId;
|
||||
const q = ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_account_effmonth", (index) =>
|
||||
index
|
||||
.eq("userId", userId)
|
||||
.eq("accountId", accountId)
|
||||
.gte("effectiveMonth", monthFrom)
|
||||
.lte("effectiveMonth", monthTo),
|
||||
)
|
||||
.order("desc");
|
||||
for await (const tx of q) transactions.push(tx);
|
||||
|
||||
const fallback = ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_account_booking", (index) =>
|
||||
index
|
||||
.eq("userId", userId)
|
||||
.eq("accountId", accountId)
|
||||
.gte("bookingDate", args.from)
|
||||
.lte("bookingDate", args.to),
|
||||
)
|
||||
.order("desc");
|
||||
for await (const tx of fallback) {
|
||||
if (tx.effectiveMonth === undefined) transactions.push(tx);
|
||||
}
|
||||
return sortTransactionsForContext(transactions, args.basis);
|
||||
}
|
||||
|
||||
const q = ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_effmonth", (index) =>
|
||||
index.eq("userId", userId).gte("effectiveMonth", monthFrom).lte("effectiveMonth", monthTo),
|
||||
)
|
||||
.order("desc");
|
||||
for await (const tx of q) transactions.push(tx);
|
||||
|
||||
const fallback = ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_booking", (index) =>
|
||||
index.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to),
|
||||
)
|
||||
.order("desc");
|
||||
for await (const tx of fallback) {
|
||||
if (tx.effectiveMonth === undefined) transactions.push(tx);
|
||||
}
|
||||
return sortTransactionsForContext(transactions, args.basis);
|
||||
}
|
||||
|
||||
if (args.accountId) {
|
||||
const accountId = args.accountId;
|
||||
const q = ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_account_booking", (index) =>
|
||||
index
|
||||
.eq("userId", userId)
|
||||
.eq("accountId", accountId)
|
||||
.gte("bookingDate", args.from)
|
||||
.lte("bookingDate", args.to),
|
||||
)
|
||||
.order("desc");
|
||||
for await (const tx of q) transactions.push(tx);
|
||||
return transactions;
|
||||
}
|
||||
|
||||
const q = ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_booking", (index) =>
|
||||
index.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to),
|
||||
)
|
||||
.order("desc");
|
||||
for await (const tx of q) transactions.push(tx);
|
||||
return sortTransactionsForContext(transactions, args.basis);
|
||||
}
|
||||
|
||||
function calculateTotals(transactions: Doc<"transactions">[]) {
|
||||
const totals = transactions.reduce(
|
||||
(acc, tx) => {
|
||||
if (tx.amount > 0) acc.income += tx.amount;
|
||||
if (tx.amount < 0) acc.expenses += tx.amount;
|
||||
acc.balance += tx.amount;
|
||||
acc.transactionCount += 1;
|
||||
return acc;
|
||||
},
|
||||
{ income: 0, expenses: 0, balance: 0, transactionCount: 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
transactionCount: totals.transactionCount,
|
||||
income: Math.round(totals.income * 100) / 100,
|
||||
expenses: Math.round(totals.expenses * 100) / 100,
|
||||
balance: Math.round(totals.balance * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildContextSummary(
|
||||
ctx: QueryCtx,
|
||||
userId: Id<"users">,
|
||||
args: ChatContextArgs,
|
||||
): Promise<{ summary: ChatContextSummary; transactions: Doc<"transactions">[] }> {
|
||||
const transactions = await loadMatchingTransactions(ctx, userId, args);
|
||||
const account = args.accountId ? await ctx.db.get(args.accountId) : null;
|
||||
|
||||
return {
|
||||
summary: {
|
||||
from: args.from,
|
||||
to: args.to,
|
||||
basis: args.basis,
|
||||
accountId: args.accountId,
|
||||
accountName: account?.userId === userId ? account.name : undefined,
|
||||
totals: calculateTotals(transactions),
|
||||
isComplete: true,
|
||||
},
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
|
||||
const contextArgsValidator = {
|
||||
from: v.string(),
|
||||
to: v.string(),
|
||||
accountId: v.optional(v.id("accounts")),
|
||||
basis: v.union(v.literal("effective"), v.literal("booking")),
|
||||
};
|
||||
|
||||
const totalsValidator = v.object({
|
||||
transactionCount: v.number(),
|
||||
income: v.number(),
|
||||
expenses: v.number(),
|
||||
balance: v.number(),
|
||||
});
|
||||
|
||||
const contextSummaryValidator = v.object({
|
||||
from: v.string(),
|
||||
to: v.string(),
|
||||
basis: v.union(v.literal("effective"), v.literal("booking")),
|
||||
accountId: v.optional(v.id("accounts")),
|
||||
accountName: v.optional(v.string()),
|
||||
totals: totalsValidator,
|
||||
isComplete: v.literal(true),
|
||||
});
|
||||
|
||||
export const getContext = query({
|
||||
args: contextArgsValidator,
|
||||
returns: contextSummaryValidator,
|
||||
handler: async (ctx, args): Promise<ChatContextSummary> => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const { summary } = await buildContextSummary(ctx, userId, args);
|
||||
return summary;
|
||||
},
|
||||
});
|
||||
|
||||
function toDisplayContextLine(
|
||||
tx: Doc<"transactions">,
|
||||
categoryById: Map<Id<"categories">, string>,
|
||||
accountById: Map<Id<"accounts">, string>,
|
||||
) {
|
||||
const date = tx.valueDate || tx.bookingDate || "n/a";
|
||||
const amount = formatEuro(tx.amount);
|
||||
const name = tx.counterparty ?? "–";
|
||||
const category = tx.categoryId ? categoryById.get(tx.categoryId) : "Ohne Kategorie";
|
||||
const account = tx.accountId ? accountById.get(tx.accountId) : "Ohne Konto";
|
||||
return `${date} | ${tx.description} (${name}) | ${amount} | ${category ?? "Ohne Kategorie"} | ${account ?? "Ohne Konto"}${
|
||||
tx.isPending ? " | offen" : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
export const getPromptContext = internalQuery({
|
||||
args: contextArgsValidator,
|
||||
returns: v.object({
|
||||
...contextSummaryValidator.fields,
|
||||
transactionLines: v.array(v.string()),
|
||||
}),
|
||||
handler: async (ctx, args): Promise<ChatPromptContext> => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const { summary, transactions } = await buildContextSummary(ctx, userId, args);
|
||||
|
||||
const categories = await ctx.db
|
||||
.query("categories")
|
||||
.withIndex("by_user", (index) => index.eq("userId", userId))
|
||||
.collect();
|
||||
const accounts = await ctx.db
|
||||
.query("accounts")
|
||||
.withIndex("by_user", (index) => index.eq("userId", userId))
|
||||
.collect();
|
||||
const categoryById = new Map(categories.map((category) => [category._id, category.name]));
|
||||
const accountById = new Map(accounts.map((account) => [account._id, account.name]));
|
||||
|
||||
return {
|
||||
...summary,
|
||||
transactionLines: transactions.map((tx) =>
|
||||
toDisplayContextLine(tx, categoryById, accountById),
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const ask = action({
|
||||
args: {
|
||||
messages: v.array(chatMessageValidator),
|
||||
from: v.string(),
|
||||
to: v.string(),
|
||||
accountId: v.optional(v.id("accounts")),
|
||||
basis: v.union(v.literal("effective"), v.literal("booking")),
|
||||
},
|
||||
returns: v.object({
|
||||
model: v.string(),
|
||||
answer: v.string(),
|
||||
usedTransactions: v.number(),
|
||||
usedBalance: v.object({
|
||||
income: v.number(),
|
||||
expenses: v.number(),
|
||||
balance: v.number(),
|
||||
}),
|
||||
}),
|
||||
handler: async (ctx, args): Promise<ChatAskResult> => {
|
||||
if (args.messages.length === 0) {
|
||||
throw new Error("Kein Nutzernachrichttext vorhanden.");
|
||||
}
|
||||
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new Error(
|
||||
"OPENAI_API_KEY ist nicht gesetzt. Bitte API-Key in den Convex-Umgebungsvariablen hinterlegen.",
|
||||
);
|
||||
}
|
||||
|
||||
await requireUserId(ctx);
|
||||
|
||||
const context: ChatPromptContext = await ctx.runQuery(internal.savingsChat.getPromptContext, {
|
||||
from: args.from,
|
||||
to: args.to,
|
||||
accountId: args.accountId,
|
||||
basis: args.basis,
|
||||
});
|
||||
|
||||
const lastMessages = args.messages
|
||||
.map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content }))
|
||||
.slice(-MAX_CONVERSATION_MESSAGES);
|
||||
|
||||
const prompt = buildPrompt(context, lastMessages);
|
||||
if (prompt.length > MAX_PROMPT_CHARACTERS) {
|
||||
throw new Error(
|
||||
"Der ausgewählte Zeitraum enthält zu viele Umsatzdetails für eine vollständige KI-Anfrage. Bitte Zeitraum oder Konto eingrenzen.",
|
||||
);
|
||||
}
|
||||
|
||||
const system = buildSystemPrompt(context);
|
||||
|
||||
const envModel = process.env.SAVINGS_CHAT_MODEL?.trim();
|
||||
const candidates = [
|
||||
envModel,
|
||||
"gpt-5.4-mini",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1",
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
let lastError: unknown;
|
||||
for (const modelName of candidates) {
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: openai(modelName),
|
||||
system,
|
||||
prompt,
|
||||
});
|
||||
return {
|
||||
model: modelName,
|
||||
answer: result.text,
|
||||
usedTransactions: context.totals.transactionCount,
|
||||
usedBalance: {
|
||||
income: context.totals.income,
|
||||
expenses: context.totals.expenses,
|
||||
balance: context.totals.balance,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
lastError instanceof Error
|
||||
? lastError.message
|
||||
: "Unbekannter Fehler bei der KI-Anfrage";
|
||||
throw new Error(`KI-Anfrage fehlgeschlagen: ${message}`);
|
||||
},
|
||||
});
|
||||
@@ -64,8 +64,14 @@ export default defineSchema({
|
||||
.index("by_user_effmonth", ["userId", "effectiveMonth"])
|
||||
.index("by_user_category", ["userId", "categoryId"])
|
||||
.index("by_user_account", ["userId", "accountId"])
|
||||
.index("by_user_account_booking", ["userId", "accountId", "bookingDate"])
|
||||
.index("by_user_account_effmonth", ["userId", "accountId", "effectiveMonth"])
|
||||
.index("by_user_dedup", ["userId", "dedupHash"])
|
||||
.index("by_user_extref", ["userId", "externalRef"]),
|
||||
.index("by_user_extref", ["userId", "externalRef"])
|
||||
.searchIndex("search_description", {
|
||||
searchField: "description",
|
||||
filterFields: ["userId"],
|
||||
}),
|
||||
|
||||
loans: defineTable({
|
||||
userId: v.id("users"),
|
||||
|
||||
@@ -39,6 +39,7 @@ export const list = query({
|
||||
from: v.optional(v.string()),
|
||||
to: v.optional(v.string()),
|
||||
categoryIds: v.optional(v.array(v.id("categories"))),
|
||||
withoutCategory: v.optional(v.boolean()),
|
||||
accountId: v.optional(v.id("accounts")),
|
||||
type: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
|
||||
pendingOnly: v.optional(v.boolean()),
|
||||
@@ -50,48 +51,57 @@ export const list = query({
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
let q = ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_booking", (q) => q.eq("userId", userId))
|
||||
.order("desc");
|
||||
|
||||
const result = await q.paginate(args.paginationOpts);
|
||||
let page = result.page;
|
||||
|
||||
if (args.from) {
|
||||
page = page.filter((tx) => !tx.bookingDate || tx.bookingDate >= args.from!);
|
||||
let q;
|
||||
if (args.search) {
|
||||
q = ctx.db
|
||||
.query("transactions")
|
||||
.withSearchIndex("search_description", (sq) =>
|
||||
sq.search("description", args.search!).eq("userId", userId),
|
||||
);
|
||||
} else {
|
||||
q = ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_booking", (iq) => {
|
||||
if (args.from && args.to) {
|
||||
return iq.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to);
|
||||
}
|
||||
if (args.from) {
|
||||
return iq.eq("userId", userId).gte("bookingDate", args.from);
|
||||
}
|
||||
if (args.to) {
|
||||
return iq.eq("userId", userId).lte("bookingDate", args.to);
|
||||
}
|
||||
return iq.eq("userId", userId);
|
||||
})
|
||||
.order("desc");
|
||||
}
|
||||
if (args.to) {
|
||||
page = page.filter((tx) => !tx.bookingDate || tx.bookingDate <= args.to!);
|
||||
|
||||
if (args.pendingOnly) {
|
||||
q = q.filter((f) => f.eq(f.field("isPending"), true));
|
||||
}
|
||||
if (args.accountId) {
|
||||
page = page.filter((tx) => tx.accountId === args.accountId);
|
||||
}
|
||||
if (args.pendingOnly) {
|
||||
page = page.filter((tx) => tx.isPending);
|
||||
q = q.filter((f) => f.eq(f.field("accountId"), args.accountId));
|
||||
}
|
||||
if (args.type === "einnahme") {
|
||||
page = page.filter((tx) => tx.amount > 0);
|
||||
q = q.filter((f) => f.gt(f.field("amount"), 0));
|
||||
}
|
||||
if (args.type === "ausgabe") {
|
||||
page = page.filter((tx) => tx.amount < 0);
|
||||
q = q.filter((f) => f.lt(f.field("amount"), 0));
|
||||
}
|
||||
if (args.categoryIds && args.categoryIds.length > 0) {
|
||||
const set = new Set(args.categoryIds);
|
||||
page = page.filter((tx) => tx.categoryId && set.has(tx.categoryId));
|
||||
}
|
||||
if (args.search) {
|
||||
const s = args.search.toLowerCase();
|
||||
page = page.filter(
|
||||
(tx) =>
|
||||
tx.description.toLowerCase().includes(s) ||
|
||||
(tx.counterparty?.toLowerCase().includes(s) ?? false) ||
|
||||
(tx.rawText?.toLowerCase().includes(s) ?? false),
|
||||
q = q.filter((f) =>
|
||||
f.or(...args.categoryIds!.map((id) => f.eq(f.field("categoryId"), id))),
|
||||
);
|
||||
}
|
||||
if (args.withoutCategory) {
|
||||
q = q.filter((f) => f.eq(f.field("categoryId"), undefined));
|
||||
}
|
||||
|
||||
const result = await q.paginate(args.paginationOpts);
|
||||
|
||||
return {
|
||||
page,
|
||||
page: result.page,
|
||||
isDone: result.isDone,
|
||||
continueCursor: result.continueCursor,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user