feat: sync savings chat history with convex
This commit is contained in:
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -35,6 +35,7 @@ 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 savingsChatHistory from "../savingsChatHistory.js";
|
||||
import type * as settings from "../settings.js";
|
||||
import type * as transactions from "../transactions.js";
|
||||
import type * as users from "../users.js";
|
||||
@@ -73,6 +74,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/seedCategories": typeof lib_seedCategories;
|
||||
loans: typeof loans;
|
||||
savingsChat: typeof savingsChat;
|
||||
savingsChatHistory: typeof savingsChatHistory;
|
||||
settings: typeof settings;
|
||||
transactions: typeof transactions;
|
||||
users: typeof users;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { convexTest } from "convex-test";
|
||||
import { generateText } from "ai";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { api, internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
@@ -57,6 +58,18 @@ vi.mock("ai", async (importOriginal) => {
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
delete modules["./savingsChat.test.ts"];
|
||||
|
||||
const historyApi = {
|
||||
listSessions: makeFunctionReference<"query">("savingsChatHistory:listSessions"),
|
||||
listMessages: makeFunctionReference<"query">("savingsChatHistory:listMessages"),
|
||||
createSession: makeFunctionReference<"mutation">("savingsChatHistory:createSession"),
|
||||
deleteSession: makeFunctionReference<"mutation">("savingsChatHistory:deleteSession"),
|
||||
importLocalSession: makeFunctionReference<"mutation">("savingsChatHistory:importLocalSession"),
|
||||
};
|
||||
|
||||
const sendMessageAction = makeFunctionReference<"action">("savingsChat:sendMessage");
|
||||
|
||||
const paginationOpts = { cursor: null, numItems: 20 };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -205,6 +218,336 @@ async function seedSavingsInsightFixture() {
|
||||
};
|
||||
}
|
||||
|
||||
describe("savingsChatHistory", () => {
|
||||
test("keeps sessions and messages scoped to the authenticated user and hides deleted sessions", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", { name: "Laptop User", email: "laptop@example.com" });
|
||||
const otherUserId = await ctx.db.insert("users", { name: "PC User", email: "pc@example.com" });
|
||||
return { userId, otherUserId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|laptop`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
const asOtherUser = t.withIdentity({
|
||||
subject: `${seeded.otherUserId}|pc`,
|
||||
tokenIdentifier: `test:${seeded.otherUserId}`,
|
||||
});
|
||||
|
||||
const imported = await asUser.mutation(historyApi.importLocalSession, {
|
||||
legacyLocalId: "local-laptop-chat",
|
||||
title: "Laptop Chat",
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_100_000,
|
||||
messages: [
|
||||
{ role: "assistant", content: "Frag mich zu deinen Umsätzen." },
|
||||
{ role: "user", content: "Wie viel habe ich gespart?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Du hast 100 EUR gespart.",
|
||||
toolTrace: [
|
||||
{
|
||||
name: "summarize_spending",
|
||||
inputSummary: "Standard-Kontext",
|
||||
resultSummary: "2 Umsätze, Saldo 100.00€, 1 Kategorien",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const userSessions = await asUser.query(historyApi.listSessions, { paginationOpts });
|
||||
expect(userSessions.page).toMatchObject([
|
||||
{ _id: imported.sessionId, title: "Laptop Chat", messageCount: 3 },
|
||||
]);
|
||||
|
||||
const otherSessions = await asOtherUser.query(historyApi.listSessions, { paginationOpts });
|
||||
expect(otherSessions.page).toEqual([]);
|
||||
|
||||
const messages = await asUser.query(historyApi.listMessages, {
|
||||
sessionId: imported.sessionId,
|
||||
paginationOpts,
|
||||
});
|
||||
expect([...messages.page].reverse()).toMatchObject([
|
||||
{ role: "assistant", content: "Frag mich zu deinen Umsätzen." },
|
||||
{ role: "user", content: "Wie viel habe ich gespart?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Du hast 100 EUR gespart.",
|
||||
toolTrace: [
|
||||
{
|
||||
name: "summarize_spending",
|
||||
inputSummary: "Standard-Kontext",
|
||||
resultSummary: "2 Umsätze, Saldo 100.00€, 1 Kategorien",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
asOtherUser.query(historyApi.listMessages, {
|
||||
sessionId: imported.sessionId,
|
||||
paginationOpts,
|
||||
}),
|
||||
).rejects.toThrow("Nicht autorisiert");
|
||||
|
||||
await expect(
|
||||
asOtherUser.action(sendMessageAction, {
|
||||
sessionId: imported.sessionId,
|
||||
content: "Kann ich diesen Chat nutzen?",
|
||||
from: "2026-02-01",
|
||||
to: "2026-02-28",
|
||||
basis: "effective",
|
||||
}),
|
||||
).rejects.toThrow("Nicht autorisiert");
|
||||
|
||||
await expect(
|
||||
asOtherUser.mutation(historyApi.deleteSession, { sessionId: imported.sessionId }),
|
||||
).rejects.toThrow("Nicht autorisiert");
|
||||
|
||||
await asUser.mutation(historyApi.deleteSession, { sessionId: imported.sessionId });
|
||||
const sessionsAfterDelete = await asUser.query(historyApi.listSessions, { paginationOpts });
|
||||
expect(sessionsAfterDelete.page).toEqual([]);
|
||||
});
|
||||
|
||||
test("imports a legacy local session once per legacy id without duplicating messages", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", { name: "Legacy User", email: "legacy@example.com" });
|
||||
return { userId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|legacy`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const input = {
|
||||
legacyLocalId: "legacy-session-1",
|
||||
title: "Alter Laptop-Chat",
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_500_000,
|
||||
messages: [
|
||||
{ role: "assistant", content: "Hallo" },
|
||||
{ role: "user", content: "Was waren meine Fixkosten?" },
|
||||
],
|
||||
};
|
||||
|
||||
const first = await asUser.mutation(historyApi.importLocalSession, input);
|
||||
const second = await asUser.mutation(historyApi.importLocalSession, input);
|
||||
|
||||
expect(second.sessionId).toBe(first.sessionId);
|
||||
|
||||
const sessions = await asUser.query(historyApi.listSessions, { paginationOpts });
|
||||
expect(sessions.page).toHaveLength(1);
|
||||
expect(sessions.page[0]).toMatchObject({
|
||||
_id: first.sessionId,
|
||||
title: "Alter Laptop-Chat",
|
||||
messageCount: 2,
|
||||
});
|
||||
|
||||
const messages = await asUser.query(historyApi.listMessages, {
|
||||
sessionId: first.sessionId,
|
||||
paginationOpts,
|
||||
});
|
||||
expect(messages.page).toHaveLength(2);
|
||||
expect([...messages.page].reverse().map((message: { content: string }) => message.content)).toEqual([
|
||||
"Hallo",
|
||||
"Was waren meine Fixkosten?",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("savingsChat.sendMessage", () => {
|
||||
test("stores user and assistant messages with tool traces and uses recent Convex history", async () => {
|
||||
const previousKey = process.env.OPENAI_API_KEY;
|
||||
const previousModel = process.env.SAVINGS_CHAT_MODEL;
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", { name: "Chat User", email: "chat@example.com" });
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-02-01",
|
||||
valueDate: "2026-02-01",
|
||||
description: "Gehalt",
|
||||
amount: 3000,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-02",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-02-10",
|
||||
valueDate: "2026-02-10",
|
||||
description: "Supermarkt",
|
||||
amount: -120,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-02",
|
||||
});
|
||||
return { userId, accountId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|chat`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
delete process.env.SAVINGS_CHAT_MODEL;
|
||||
|
||||
const created = await asUser.mutation(historyApi.createSession, { title: "Neuer Chat" });
|
||||
for (let index = 0; index < 24; index++) {
|
||||
await asUser.mutation(historyApi.importLocalSession, {
|
||||
legacyLocalId: `history-${index}`,
|
||||
title: `Historie ${index}`,
|
||||
createdAt: index,
|
||||
updatedAt: index,
|
||||
messages: [{ role: "user", content: `Separate legacy message ${index}` }],
|
||||
});
|
||||
}
|
||||
|
||||
await t.run(async (ctx) => {
|
||||
for (let index = 0; index < 24; index++) {
|
||||
await ctx.db.insert("chatMessages", {
|
||||
userId: seeded.userId,
|
||||
sessionId: created.sessionId,
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `Vorige Nachricht ${index}`,
|
||||
createdAt: index,
|
||||
});
|
||||
}
|
||||
await ctx.db.patch(created.sessionId, {
|
||||
messageCount: 25,
|
||||
updatedAt: 24,
|
||||
});
|
||||
});
|
||||
|
||||
const result = await asUser.action(sendMessageAction, {
|
||||
sessionId: created.sessionId,
|
||||
content: "Wie sieht Februar aus?",
|
||||
from: "2026-02-01",
|
||||
to: "2026-02-28",
|
||||
accountId: seeded.accountId as Id<"accounts">,
|
||||
basis: "effective",
|
||||
});
|
||||
|
||||
expect(result.answer).toBe("Agenten-Antwort");
|
||||
expect(result.toolTrace).toHaveLength(2);
|
||||
|
||||
const generateCall = vi.mocked(generateText).mock.calls[0][0] as {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
};
|
||||
expect(generateCall.messages).toHaveLength(20);
|
||||
expect(generateCall.messages[0].content).toBe("Vorige Nachricht 6");
|
||||
expect(generateCall.messages[generateCall.messages.length - 1]).toEqual({
|
||||
role: "user",
|
||||
content: "Wie sieht Februar aus?",
|
||||
});
|
||||
|
||||
const messages = await asUser.query(historyApi.listMessages, {
|
||||
sessionId: created.sessionId,
|
||||
paginationOpts: { cursor: null, numItems: 40 },
|
||||
});
|
||||
expect([...messages.page].reverse().slice(-2)).toMatchObject([
|
||||
{ role: "user", content: "Wie sieht Februar aus?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Agenten-Antwort",
|
||||
toolTrace: [
|
||||
{
|
||||
name: "get_transactions",
|
||||
inputSummary: "2026-02-01 bis 2026-02-28, Limit 2",
|
||||
resultSummary: "2 Umsätze, Saldo 2880.00€, vollständig",
|
||||
},
|
||||
{
|
||||
name: "summarize_spending",
|
||||
inputSummary: "2026-02-01 bis 2026-02-28",
|
||||
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
if (previousKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previousKey;
|
||||
}
|
||||
if (previousModel === undefined) {
|
||||
delete process.env.SAVINGS_CHAT_MODEL;
|
||||
} else {
|
||||
process.env.SAVINGS_CHAT_MODEL = previousModel;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("stores an assistant failure message when AI generation fails after the user message is saved", async () => {
|
||||
const previousKey = process.env.OPENAI_API_KEY;
|
||||
const previousModel = process.env.SAVINGS_CHAT_MODEL;
|
||||
const t = convexTest(schema, modules);
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", { name: "Failure User", email: "failure@example.com" });
|
||||
return { userId };
|
||||
});
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|failure`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
delete process.env.SAVINGS_CHAT_MODEL;
|
||||
vi.mocked(generateText)
|
||||
.mockRejectedValueOnce(new Error("model failed 1"))
|
||||
.mockRejectedValueOnce(new Error("model failed 2"))
|
||||
.mockRejectedValueOnce(new Error("model failed 3"));
|
||||
|
||||
const created = await asUser.mutation(historyApi.createSession, { title: "Neuer Chat" });
|
||||
await expect(
|
||||
asUser.action(sendMessageAction, {
|
||||
sessionId: created.sessionId,
|
||||
content: "Warum geht das nicht?",
|
||||
from: "2026-02-01",
|
||||
to: "2026-02-28",
|
||||
basis: "effective",
|
||||
}),
|
||||
).rejects.toThrow("KI-Anfrage fehlgeschlagen");
|
||||
|
||||
const messages = await asUser.query(historyApi.listMessages, {
|
||||
sessionId: created.sessionId,
|
||||
paginationOpts: { cursor: null, numItems: 10 },
|
||||
});
|
||||
expect([...messages.page].reverse().slice(-2)).toMatchObject([
|
||||
{ role: "user", content: "Warum geht das nicht?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
if (previousKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previousKey;
|
||||
}
|
||||
if (previousModel === undefined) {
|
||||
delete process.env.SAVINGS_CHAT_MODEL;
|
||||
} else {
|
||||
process.env.SAVINGS_CHAT_MODEL = previousModel;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("savingsChat.getContext", () => {
|
||||
test("counts and sums every matching transaction before applying prompt limits", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
266
convex/savingsChatHistory.ts
Normal file
266
convex/savingsChatHistory.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { paginationOptsValidator, paginationResultValidator } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { assertOwned, requireUserId } from "./lib/helpers";
|
||||
|
||||
const initialAssistantMessage =
|
||||
"Frag mich zu deinen Umsätzen – ich werte sie im aktuellen Zeitraum aus.";
|
||||
|
||||
const toolTraceValidator = v.object({
|
||||
name: v.string(),
|
||||
inputSummary: v.string(),
|
||||
resultSummary: v.string(),
|
||||
});
|
||||
|
||||
const chatRoleValidator = v.union(v.literal("user"), v.literal("assistant"));
|
||||
|
||||
const importMessageValidator = v.object({
|
||||
role: chatRoleValidator,
|
||||
content: v.string(),
|
||||
toolTrace: v.optional(v.array(toolTraceValidator)),
|
||||
});
|
||||
|
||||
const sessionValidator = v.object({
|
||||
_id: v.id("chatSessions"),
|
||||
_creationTime: v.number(),
|
||||
userId: v.id("users"),
|
||||
title: v.string(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
messageCount: v.number(),
|
||||
legacyLocalId: v.optional(v.string()),
|
||||
isDeleted: v.boolean(),
|
||||
});
|
||||
|
||||
const messageValidator = v.object({
|
||||
_id: v.id("chatMessages"),
|
||||
_creationTime: v.number(),
|
||||
userId: v.id("users"),
|
||||
sessionId: v.id("chatSessions"),
|
||||
role: chatRoleValidator,
|
||||
content: v.string(),
|
||||
createdAt: v.number(),
|
||||
toolTrace: v.optional(v.array(toolTraceValidator)),
|
||||
});
|
||||
|
||||
const promptMessageValidator = v.object({
|
||||
role: chatRoleValidator,
|
||||
content: v.string(),
|
||||
});
|
||||
|
||||
function normalizeTitle(title: string | undefined) {
|
||||
const trimmed = title?.trim();
|
||||
return trimmed ? trimmed : "Neuer Chat";
|
||||
}
|
||||
|
||||
function titleFromContent(content: string) {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return "Neuer Chat";
|
||||
return trimmed.length > 44 ? `${trimmed.slice(0, 44)}…` : trimmed;
|
||||
}
|
||||
|
||||
async function requireOwnedSession(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
sessionId: Id<"chatSessions">,
|
||||
userId: Id<"users">,
|
||||
) {
|
||||
const session = await assertOwned(await ctx.db.get(sessionId), userId, "Chat");
|
||||
if (session.isDeleted) throw new Error("Chat nicht gefunden");
|
||||
return session;
|
||||
}
|
||||
|
||||
export const listSessions = query({
|
||||
args: { paginationOpts: paginationOptsValidator },
|
||||
returns: paginationResultValidator(sessionValidator),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
return await ctx.db
|
||||
.query("chatSessions")
|
||||
.withIndex("by_user_deleted_updated", (index) =>
|
||||
index.eq("userId", userId).eq("isDeleted", false),
|
||||
)
|
||||
.order("desc")
|
||||
.paginate(args.paginationOpts);
|
||||
},
|
||||
});
|
||||
|
||||
export const listMessages = query({
|
||||
args: {
|
||||
sessionId: v.id("chatSessions"),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
},
|
||||
returns: paginationResultValidator(messageValidator),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
await requireOwnedSession(ctx, args.sessionId, userId);
|
||||
return await ctx.db
|
||||
.query("chatMessages")
|
||||
.withIndex("by_user_session_created", (index) =>
|
||||
index.eq("userId", userId).eq("sessionId", args.sessionId),
|
||||
)
|
||||
.order("desc")
|
||||
.paginate(args.paginationOpts);
|
||||
},
|
||||
});
|
||||
|
||||
export const createSession = mutation({
|
||||
args: { title: v.optional(v.string()) },
|
||||
returns: v.object({ sessionId: v.id("chatSessions") }),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const now = Date.now();
|
||||
const sessionId = await ctx.db.insert("chatSessions", {
|
||||
userId,
|
||||
title: normalizeTitle(args.title),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messageCount: 1,
|
||||
isDeleted: false,
|
||||
});
|
||||
await ctx.db.insert("chatMessages", {
|
||||
userId,
|
||||
sessionId,
|
||||
role: "assistant",
|
||||
content: initialAssistantMessage,
|
||||
createdAt: now,
|
||||
});
|
||||
return { sessionId };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteSession = mutation({
|
||||
args: { sessionId: v.id("chatSessions") },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
await requireOwnedSession(ctx, args.sessionId, userId);
|
||||
await ctx.db.patch(args.sessionId, {
|
||||
isDeleted: true,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const importLocalSession = mutation({
|
||||
args: {
|
||||
legacyLocalId: v.string(),
|
||||
title: v.string(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
messages: v.array(importMessageValidator),
|
||||
},
|
||||
returns: v.object({ sessionId: v.id("chatSessions") }),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("chatSessions")
|
||||
.withIndex("by_user_legacyLocalId", (index) =>
|
||||
index.eq("userId", userId).eq("legacyLocalId", args.legacyLocalId),
|
||||
)
|
||||
.unique();
|
||||
if (existing) return { sessionId: existing._id };
|
||||
|
||||
const sessionId = await ctx.db.insert("chatSessions", {
|
||||
userId,
|
||||
title: normalizeTitle(args.title),
|
||||
createdAt: args.createdAt,
|
||||
updatedAt: args.updatedAt,
|
||||
messageCount: args.messages.length,
|
||||
legacyLocalId: args.legacyLocalId,
|
||||
isDeleted: false,
|
||||
});
|
||||
|
||||
for (const [index, message] of args.messages.entries()) {
|
||||
await ctx.db.insert("chatMessages", {
|
||||
userId,
|
||||
sessionId,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
createdAt: args.createdAt + index,
|
||||
...(message.toolTrace ? { toolTrace: message.toolTrace } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return { sessionId };
|
||||
},
|
||||
});
|
||||
|
||||
export const appendUserMessage = internalMutation({
|
||||
args: {
|
||||
sessionId: v.id("chatSessions"),
|
||||
content: v.string(),
|
||||
},
|
||||
returns: v.object({ messageId: v.id("chatMessages") }),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const session = await requireOwnedSession(ctx, args.sessionId, userId);
|
||||
const now = Date.now();
|
||||
const messageId = await ctx.db.insert("chatMessages", {
|
||||
userId,
|
||||
sessionId: args.sessionId,
|
||||
role: "user",
|
||||
content: args.content,
|
||||
createdAt: now,
|
||||
});
|
||||
await ctx.db.patch(args.sessionId, {
|
||||
title: session.title === "Neuer Chat" ? titleFromContent(args.content) : session.title,
|
||||
updatedAt: now,
|
||||
messageCount: session.messageCount + 1,
|
||||
});
|
||||
return { messageId };
|
||||
},
|
||||
});
|
||||
|
||||
export const appendAssistantMessage = internalMutation({
|
||||
args: {
|
||||
sessionId: v.id("chatSessions"),
|
||||
content: v.string(),
|
||||
toolTrace: v.optional(v.array(toolTraceValidator)),
|
||||
},
|
||||
returns: v.object({ messageId: v.id("chatMessages") }),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const session = await requireOwnedSession(ctx, args.sessionId, userId);
|
||||
const now = Date.now();
|
||||
const messageId = await ctx.db.insert("chatMessages", {
|
||||
userId,
|
||||
sessionId: args.sessionId,
|
||||
role: "assistant",
|
||||
content: args.content,
|
||||
createdAt: now,
|
||||
...(args.toolTrace ? { toolTrace: args.toolTrace } : {}),
|
||||
});
|
||||
await ctx.db.patch(args.sessionId, {
|
||||
updatedAt: now,
|
||||
messageCount: session.messageCount + 1,
|
||||
});
|
||||
return { messageId };
|
||||
},
|
||||
});
|
||||
|
||||
export const getRecentMessagesForPrompt = internalQuery({
|
||||
args: {
|
||||
sessionId: v.id("chatSessions"),
|
||||
limit: v.number(),
|
||||
},
|
||||
returns: v.array(promptMessageValidator),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
await requireOwnedSession(ctx, args.sessionId, userId);
|
||||
const limit = Math.max(1, Math.min(50, Math.floor(args.limit)));
|
||||
const messages = await ctx.db
|
||||
.query("chatMessages")
|
||||
.withIndex("by_user_session_created", (index) =>
|
||||
index.eq("userId", userId).eq("sessionId", args.sessionId),
|
||||
)
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
return messages.reverse().map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,12 @@ const loanStatus = v.union(
|
||||
v.literal("abbezahlt"),
|
||||
v.literal("pausiert"),
|
||||
);
|
||||
const chatRole = v.union(v.literal("user"), v.literal("assistant"));
|
||||
const chatToolTrace = v.object({
|
||||
name: v.string(),
|
||||
inputSummary: v.string(),
|
||||
resultSummary: v.string(),
|
||||
});
|
||||
|
||||
export default defineSchema({
|
||||
...authTables,
|
||||
@@ -175,4 +181,25 @@ export default defineSchema({
|
||||
isDecoupled: v.optional(v.boolean()),
|
||||
submittedTan: v.optional(v.string()),
|
||||
}).index("by_user", ["userId"]),
|
||||
|
||||
chatSessions: defineTable({
|
||||
userId: v.id("users"),
|
||||
title: v.string(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
messageCount: v.number(),
|
||||
legacyLocalId: v.optional(v.string()),
|
||||
isDeleted: v.boolean(),
|
||||
})
|
||||
.index("by_user_deleted_updated", ["userId", "isDeleted", "updatedAt"])
|
||||
.index("by_user_legacyLocalId", ["userId", "legacyLocalId"]),
|
||||
|
||||
chatMessages: defineTable({
|
||||
userId: v.id("users"),
|
||||
sessionId: v.id("chatSessions"),
|
||||
role: chatRole,
|
||||
content: v.string(),
|
||||
createdAt: v.number(),
|
||||
toolTrace: v.optional(v.array(chatToolTrace)),
|
||||
}).index("by_user_session_created", ["userId", "sessionId", "createdAt"]),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user