feat: sync savings chat history with convex
This commit is contained in:
@@ -7,7 +7,7 @@ import { z } from "zod";
|
||||
import { addMonthsToMonthKey, bookingMonth, monthKeyFromBasis } from "./lib/month";
|
||||
import { requireUserId } from "./lib/helpers";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { QueryCtx } from "./_generated/server";
|
||||
import type { ActionCtx, QueryCtx } from "./_generated/server";
|
||||
|
||||
type ChatRole = "user" | "assistant";
|
||||
type ChatMessage = { role: ChatRole; content: string };
|
||||
@@ -1900,6 +1900,202 @@ const fixedCostsForecastToolInputSchema = z.object({
|
||||
asOf: z.string().optional().describe("Stichtag für den Start der Prognose im Format YYYY-MM-DD."),
|
||||
});
|
||||
|
||||
async function generateSavingsChatResponse(
|
||||
ctx: ActionCtx,
|
||||
args: ChatContextArgs & { messages: ChatMessage[] },
|
||||
): 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 scope: AgentToolScope = {
|
||||
from: args.from,
|
||||
to: args.to,
|
||||
accountId: args.accountId,
|
||||
basis: args.basis,
|
||||
};
|
||||
|
||||
const selectedSummary: {
|
||||
totalCount: number;
|
||||
totals: { income: number; expenses: number; balance: number; transactionCount: number };
|
||||
accountName?: string;
|
||||
} = await ctx.runQuery(internal.savingsChat.getTransactionsTool, {
|
||||
scope,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const lastMessages = args.messages
|
||||
.map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content }))
|
||||
.slice(-MAX_CONVERSATION_MESSAGES);
|
||||
|
||||
const system = buildSystemPrompt({
|
||||
from: args.from,
|
||||
to: args.to,
|
||||
basis: args.basis,
|
||||
accountName: selectedSummary.accountName,
|
||||
});
|
||||
|
||||
const savingsTools = {
|
||||
get_transactions: tool({
|
||||
description:
|
||||
"Ruft passende Umsätze read-only ab. Nutze dieses Tool für Detailfragen, Suche nach Gegenparteien/Beschreibungen oder Belege einzelner Aussagen. Es liefert exakte Summen und nur begrenzte, sanitizte Zeilen.",
|
||||
inputSchema: transactionToolInputSchema,
|
||||
execute: async (input) =>
|
||||
await ctx.runQuery(internal.savingsChat.getTransactionsTool, {
|
||||
scope,
|
||||
...input,
|
||||
}),
|
||||
}),
|
||||
summarize_spending: tool({
|
||||
description:
|
||||
"Berechnet read-only exakte Summen, Monatsverläufe, Kategorien sowie fixe und variable Ausgaben für den ausgewählten oder angegebenen Zeitraum.",
|
||||
inputSchema: summaryToolInputSchema,
|
||||
execute: async (input) =>
|
||||
await ctx.runQuery(internal.savingsChat.summarizeSpendingTool, {
|
||||
scope,
|
||||
...input,
|
||||
}),
|
||||
}),
|
||||
forecast_cashflow: tool({
|
||||
description:
|
||||
"Erstellt eine deterministische Cashflow-Prognose für 1 bis 3 kommende Monate aus vollständigen historischen Monaten. Nutze es für Sparrate, Monatsüberschuss und kurzfristige Prognosen.",
|
||||
inputSchema: forecastToolInputSchema,
|
||||
execute: async (input) =>
|
||||
await ctx.runQuery(internal.savingsChat.forecastCashflowTool, {
|
||||
scope,
|
||||
...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 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,
|
||||
messages: lastMessages,
|
||||
tools: savingsTools,
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
return {
|
||||
model: modelName,
|
||||
answer: result.text,
|
||||
usedTransactions: selectedSummary.totals.transactionCount,
|
||||
usedBalance: {
|
||||
income: selectedSummary.totals.income,
|
||||
expenses: selectedSummary.totals.expenses,
|
||||
balance: selectedSummary.totals.balance,
|
||||
},
|
||||
toolTrace: buildToolTraceFromSteps(result.steps),
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
lastError instanceof Error
|
||||
? lastError.message
|
||||
: "Unbekannter Fehler bei der KI-Anfrage";
|
||||
throw new Error(`KI-Anfrage fehlgeschlagen: ${message}`);
|
||||
}
|
||||
|
||||
export const ask = action({
|
||||
args: {
|
||||
messages: v.array(chatMessageValidator),
|
||||
@@ -1920,195 +2116,74 @@ export const ask = action({
|
||||
toolTrace: v.array(toolTraceValidator),
|
||||
}),
|
||||
handler: async (ctx, args): Promise<ChatAskResult> => {
|
||||
if (args.messages.length === 0) {
|
||||
return await generateSavingsChatResponse(ctx, {
|
||||
...args,
|
||||
messages: args.messages.map((message) => ({
|
||||
role: normalizeRole(message.role),
|
||||
content: message.content,
|
||||
})),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const sendMessage = action({
|
||||
args: {
|
||||
sessionId: v.id("chatSessions"),
|
||||
content: v.string(),
|
||||
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(),
|
||||
}),
|
||||
toolTrace: v.array(toolTraceValidator),
|
||||
}),
|
||||
handler: async (ctx, args): Promise<ChatAskResult> => {
|
||||
const content = args.content.trim();
|
||||
if (!content) {
|
||||
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 scope: AgentToolScope = {
|
||||
from: args.from,
|
||||
to: args.to,
|
||||
accountId: args.accountId,
|
||||
basis: args.basis,
|
||||
};
|
||||
|
||||
const selectedSummary: {
|
||||
totalCount: number;
|
||||
totals: { income: number; expenses: number; balance: number; transactionCount: number };
|
||||
accountName?: string;
|
||||
} = await ctx.runQuery(internal.savingsChat.getTransactionsTool, {
|
||||
scope,
|
||||
limit: 1,
|
||||
await ctx.runMutation(internal.savingsChatHistory.appendUserMessage, {
|
||||
sessionId: args.sessionId,
|
||||
content,
|
||||
});
|
||||
|
||||
const lastMessages = args.messages
|
||||
.map((message): ChatMessage => ({ role: normalizeRole(message.role), content: message.content }))
|
||||
.slice(-MAX_CONVERSATION_MESSAGES);
|
||||
|
||||
const system = buildSystemPrompt({
|
||||
from: args.from,
|
||||
to: args.to,
|
||||
basis: args.basis,
|
||||
accountName: selectedSummary.accountName,
|
||||
});
|
||||
|
||||
const savingsTools = {
|
||||
get_transactions: tool({
|
||||
description:
|
||||
"Ruft passende Umsätze read-only ab. Nutze dieses Tool für Detailfragen, Suche nach Gegenparteien/Beschreibungen oder Belege einzelner Aussagen. Es liefert exakte Summen und nur begrenzte, sanitizte Zeilen.",
|
||||
inputSchema: transactionToolInputSchema,
|
||||
execute: async (input) =>
|
||||
await ctx.runQuery(internal.savingsChat.getTransactionsTool, {
|
||||
scope,
|
||||
...input,
|
||||
}),
|
||||
}),
|
||||
summarize_spending: tool({
|
||||
description:
|
||||
"Berechnet read-only exakte Summen, Monatsverläufe, Kategorien sowie fixe und variable Ausgaben für den ausgewählten oder angegebenen Zeitraum.",
|
||||
inputSchema: summaryToolInputSchema,
|
||||
execute: async (input) =>
|
||||
await ctx.runQuery(internal.savingsChat.summarizeSpendingTool, {
|
||||
scope,
|
||||
...input,
|
||||
}),
|
||||
}),
|
||||
forecast_cashflow: tool({
|
||||
description:
|
||||
"Erstellt eine deterministische Cashflow-Prognose für 1 bis 3 kommende Monate aus vollständigen historischen Monaten. Nutze es für Sparrate, Monatsüberschuss und kurzfristige Prognosen.",
|
||||
inputSchema: forecastToolInputSchema,
|
||||
execute: async (input) =>
|
||||
await ctx.runQuery(internal.savingsChat.forecastCashflowTool, {
|
||||
scope,
|
||||
...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 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,
|
||||
messages: lastMessages,
|
||||
tools: savingsTools,
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
return {
|
||||
model: modelName,
|
||||
answer: result.text,
|
||||
usedTransactions: selectedSummary.totals.transactionCount,
|
||||
usedBalance: {
|
||||
income: selectedSummary.totals.income,
|
||||
expenses: selectedSummary.totals.expenses,
|
||||
balance: selectedSummary.totals.balance,
|
||||
},
|
||||
toolTrace: buildToolTraceFromSteps(result.steps),
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
const messages: ChatMessage[] = await ctx.runQuery(
|
||||
internal.savingsChatHistory.getRecentMessagesForPrompt,
|
||||
{
|
||||
sessionId: args.sessionId,
|
||||
limit: MAX_CONVERSATION_MESSAGES,
|
||||
},
|
||||
);
|
||||
let response: ChatAskResult;
|
||||
try {
|
||||
response = await generateSavingsChatResponse(ctx, {
|
||||
from: args.from,
|
||||
to: args.to,
|
||||
accountId: args.accountId,
|
||||
basis: args.basis,
|
||||
messages,
|
||||
});
|
||||
} catch (error) {
|
||||
await ctx.runMutation(internal.savingsChatHistory.appendAssistantMessage, {
|
||||
sessionId: args.sessionId,
|
||||
content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message =
|
||||
lastError instanceof Error
|
||||
? lastError.message
|
||||
: "Unbekannter Fehler bei der KI-Anfrage";
|
||||
throw new Error(`KI-Anfrage fehlgeschlagen: ${message}`);
|
||||
await ctx.runMutation(internal.savingsChatHistory.appendAssistantMessage, {
|
||||
sessionId: args.sessionId,
|
||||
content: response.answer,
|
||||
toolTrace: response.toolTrace,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user