From 1092ff2648b863ffa9c69c0e045505b57d1f8738 Mon Sep 17 00:00:00 2001 From: Linus Waldowsky Date: Mon, 2 Feb 2026 22:44:08 +0100 Subject: [PATCH] refactor: improve AI event handling and conflict display in chat - AI fetches events on-demand via callbacks for better efficiency - Add conflict detection with warning display when proposing overlapping events - Improve event search and display in chat interface - Load full chat history for display while limiting AI context --- CLAUDE.md | 52 ++++++++-- apps/client/src/app/editEvent.tsx | 29 ++---- .../src/components/ProposedEventCard.tsx | 27 +++++- apps/server/src/ai/GPTAdapter.ts | 40 +++++--- apps/server/src/ai/utils/eventFormatter.ts | 30 +----- apps/server/src/ai/utils/index.ts | 7 +- apps/server/src/ai/utils/systemPrompt.ts | 34 ++++--- apps/server/src/ai/utils/toolDefinitions.ts | 19 ++++ apps/server/src/ai/utils/toolExecutor.ts | 94 ++++++++++++++++--- apps/server/src/app.ts | 4 +- apps/server/src/logging/Logged.ts | 4 +- .../repositories/mongo/MongoChatRepository.ts | 38 +++++++- .../mongo/MongoEventRepository.ts | 8 ++ .../repositories/mongo/models/ChatModel.ts | 11 +++ apps/server/src/services/ChatService.ts | 65 ++++++++++++- .../src/services/interfaces/AIProvider.ts | 11 ++- .../src/services/interfaces/ChatRepository.ts | 5 + .../services/interfaces/EventRepository.ts | 1 + packages/shared/src/models/ChatMessage.ts | 7 ++ 19 files changed, 367 insertions(+), 119 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 676f306..b24e2f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -266,10 +266,10 @@ src/ │ ├── index.ts # Re-exports GPTAdapter │ └── utils/ # Shared AI utilities (provider-agnostic) │ ├── index.ts # Re-exports -│ ├── eventFormatter.ts # formatExistingEvents() for system prompt +│ ├── eventFormatter.ts # Re-exports formatDate/Time/DateTime from shared │ ├── systemPrompt.ts # buildSystemPrompt() - German calendar assistant prompt │ ├── toolDefinitions.ts # TOOL_DEFINITIONS - provider-agnostic tool specs -│ └── toolExecutor.ts # executeToolCall() - handles getDay, proposeCreate/Update/Delete, searchEvents +│ └── toolExecutor.ts # executeToolCall() - handles getDay, proposeCreate/Update/Delete, searchEvents, getEventsInRange ├── utils/ │ ├── jwt.ts # signToken(), verifyToken() - NOT USED YET (no JWT) │ ├── password.ts # hash(), compare() using bcrypt @@ -325,10 +325,12 @@ src/ - `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule?, exceptionDates? - `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances) - `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges? -- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?, deleteMode?, occurrenceDate? +- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?, deleteMode?, occurrenceDate?, conflictingEvents? - Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject - `respondedAction` tracks user response per proposal (not per message) - `deleteMode` ('single' | 'future' | 'all') and `occurrenceDate` for recurring event deletion + - `conflictingEvents` contains events that overlap with the proposed time (for conflict warnings) +- `ConflictingEvent`: title, startTime, endTime - simplified event info for conflict display - `RecurringDeleteMode`: 'single' | 'future' | 'all' - delete modes for recurring events - `DeleteRecurringEventDTO`: mode, occurrenceDate? - DTO for recurring event deletion - `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading) @@ -342,6 +344,39 @@ src/ - `Day`: "Monday" | "Tuesday" | ... | "Sunday" - `Month`: "January" | "February" | ... | "December" +### AI Context Architecture + +The AI assistant fetches calendar data on-demand rather than receiving pre-loaded events. This reduces token usage significantly. + +**AIContext Interface:** +```typescript +interface AIContext { + userId: string; + conversationHistory: ChatMessage[]; // Last 20 messages for context + currentDate: Date; + // Callbacks for on-demand data fetching: + fetchEventsInRange: (start: Date, end: Date) => Promise; + searchEvents: (query: string) => Promise; + fetchEventById: (eventId: string) => Promise; +} +``` + +**Available AI Tools:** +- `getDay` - Calculate relative dates (e.g., "next Friday") +- `getCurrentDateTime` - Get current timestamp +- `proposeCreateEvent` - Propose new event (includes automatic conflict detection) +- `proposeUpdateEvent` - Propose event modification +- `proposeDeleteEvent` - Propose event deletion (supports recurring delete modes) +- `searchEvents` - Search events by title (returns IDs for update/delete) +- `getEventsInRange` - Load events for a date range (for "what's today?" queries) + +**Conflict Detection:** +When creating events, `toolExecutor` automatically: +1. Fetches events for the target day via `fetchEventsInRange` +2. Checks for time overlaps using `occurrenceStart/occurrenceEnd` (important for recurring events) +3. Returns `conflictingEvents` array in the proposal for UI display +4. Adds ⚠️ warning to tool result so AI can inform user + ### Database Abstraction The repository pattern allows swapping databases: @@ -402,7 +437,6 @@ The decorator uses a Proxy to intercept method calls lazily, preserves sync/asyn **Log Summarization:** The `@Logged` decorator automatically summarizes large arguments to keep logs readable: - `conversationHistory` → `"[5 messages]"` -- `existingEvents` → `"[3 events]"` - `proposedChanges` → logged in full (for debugging AI issues) - Long strings (>100 chars) → truncated - Arrays → `"[Array(n)]"` @@ -474,9 +508,11 @@ NODE_ENV=development # development = pretty logs, production = JSON - `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse, updateProposalEvent) - `ChatRepository` interface: updateMessage(), updateProposalResponse(), updateProposalEvent() for per-proposal tracking - `GPTAdapter`: Full implementation with OpenAI GPT (gpt-4o-mini model), function calling for calendar operations, collects multiple proposals per response - - `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor, eventFormatter) - - `ai/utils/systemPrompt`: Includes RRULE documentation - AI knows to create separate events when times differ by day, warns AI not to put RRULE in description field - - `ai/utils/toolDefinitions`: proposeUpdateEvent supports `isRecurring` and `recurrenceRule` parameters for adding UNTIL or modifying recurrence + - `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor) + - `ai/utils/systemPrompt`: AI fetches events on-demand (no pre-loaded context), includes RRULE documentation, warns AI not to put RRULE in description field, instructs AI not to show event IDs to users + - `ai/utils/toolDefinitions`: proposeUpdateEvent supports `isRecurring` and `recurrenceRule` parameters, getEventsInRange tool for on-demand event loading + - `ai/utils/toolExecutor`: Async execution, conflict detection uses `occurrenceStart/occurrenceEnd` for recurring events, returns `conflictingEvents` in proposals + - `MongoEventRepository`: Includes `searchByTitle()` for case-insensitive title search - `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it), filters out exceptionDates - `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator - All repositories and GPTAdapter decorated with @Logged for automatic method logging @@ -538,7 +574,7 @@ NODE_ENV=development # development = pretty logs, production = JSON - `ModalBase`: Reusable modal wrapper with backdrop (absolute-positioned behind card), uses CardBase internally - provides click-outside-to-close, Android back button support, and proper scrolling on Android - `EventCardBase`: Event card with date/time/recurring icons - uses CardBase for structure - `EventCard`: Uses EventCardBase + edit/delete buttons (TouchableOpacity with delayPressIn for scroll-friendly touch handling) -- `ProposedEventCard`: Uses EventCardBase + confirm/reject/edit buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates). Edit button allows modifying proposals before confirming. +- `ProposedEventCard`: Uses EventCardBase + confirm/reject/edit buttons for chat proposals, displays green highlighted text for new changes ("Neue Ausnahme: [date]" for single delete, "Neues Ende: [date]" for UNTIL updates), shows yellow conflict warnings when proposed time overlaps with existing events. Edit button allows modifying proposals before confirming. - `DeleteEventModal`: Delete confirmation modal using ModalBase - shows three options for recurring events (single/future/all), simple confirm for non-recurring - `EventOverlay` (in calendar.tsx): Day events overlay using ModalBase - shows EventCards for selected day - `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.) diff --git a/apps/client/src/app/editEvent.tsx b/apps/client/src/app/editEvent.tsx index 0cabf53..b6a9720 100644 --- a/apps/client/src/app/editEvent.tsx +++ b/apps/client/src/app/editEvent.tsx @@ -22,9 +22,6 @@ import { EventService, ChatService } from "../services"; import { buildRRule, CreateEventDTO } from "@calchat/shared"; import { useChatStore } from "../stores"; -// Direct store access for getting current state in callbacks -const getChatStoreState = () => useChatStore.getState(); - type EditEventTextFieldProps = { titel: string; text?: string; @@ -443,31 +440,25 @@ const EditEventScreen = () => { : undefined, }; - // Chat mode: update proposal locally and on server + // Chat mode: update proposal on server and sync response to local store if (mode === "chat" && proposalContext) { try { const context = JSON.parse(proposalContext) as ProposalContext; - // Update locally in ChatStore - const currentMessages = getChatStoreState().messages; - const message = currentMessages.find((m) => m.id === context.messageId); - - if (message?.proposedChanges) { - const updatedProposals = message.proposedChanges.map((p) => - p.id === context.proposalId ? { ...p, event: eventObject } : p, - ); - updateMessage(context.messageId, { - proposedChanges: updatedProposals, - }); - } - - // Persist to server - await ChatService.updateProposalEvent( + // Persist to server - returns updated message with recalculated conflictingEvents + const updatedMessage = await ChatService.updateProposalEvent( context.messageId, context.proposalId, eventObject, ); + // Update local ChatStore with server response (includes updated conflicts) + if (updatedMessage?.proposedChanges) { + updateMessage(context.messageId, { + proposedChanges: updatedMessage.proposedChanges, + }); + } + router.back(); } catch (error) { console.error("Failed to update proposal:", error); diff --git a/apps/client/src/components/ProposedEventCard.tsx b/apps/client/src/components/ProposedEventCard.tsx index bc94c7b..4d50f5f 100644 --- a/apps/client/src/components/ProposedEventCard.tsx +++ b/apps/client/src/components/ProposedEventCard.tsx @@ -1,6 +1,6 @@ import { View, Text, Pressable } from "react-native"; -import { Feather } from "@expo/vector-icons"; -import { ProposedEventChange, formatDate } from "@calchat/shared"; +import { Feather, Ionicons } from "@expo/vector-icons"; +import { ProposedEventChange, formatDate, formatTime } from "@calchat/shared"; import { rrulestr } from "rrule"; import { useThemeStore } from "../stores/ThemeStore"; import { EventCardBase } from "./EventCardBase"; @@ -143,6 +143,29 @@ export const ProposedEventCard = ({ )} + {/* Show conflicting events warning */} + {proposedChange.conflictingEvents && + proposedChange.conflictingEvents.length > 0 && ( + + {proposedChange.conflictingEvents.map((conflict, index) => ( + + + + Konflikt: {conflict.title} ({formatTime(conflict.startTime)}{" "} + - {formatTime(conflict.endTime)}) + + + ))} + + )} = []; + for (const toolCall of assistantMessage.tool_calls) { - // Skip non-function tool calls if (toolCall.type !== "function") continue; const { name, arguments: argsRaw } = toolCall.function; const args = JSON.parse(argsRaw); - const result = executeToolCall(name, args, context); + const result = await executeToolCall(name, args, context); - // If the tool returned a proposedChange, add it to the array with unique ID + // Collect proposed changes if (result.proposedChange) { proposedChanges.push({ id: `proposal-${proposalIndex++}`, @@ -105,17 +114,22 @@ export class GPTAdapter implements AIProvider { }); } - // Add assistant message with tool call - messages.push({ - role: "assistant", - tool_calls: [toolCall], - }); + toolResults.push({ toolCall, content: result.content }); + } - // Add tool result + // Add assistant message with ALL tool calls at once + messages.push({ + role: "assistant", + tool_calls: assistantMessage.tool_calls, + content: assistantMessage.content, + }); + + // Add all tool results + for (const { toolCall, content } of toolResults) { messages.push({ role: "tool", tool_call_id: toolCall.id, - content: result.content, + content, }); } } diff --git a/apps/server/src/ai/utils/eventFormatter.ts b/apps/server/src/ai/utils/eventFormatter.ts index f28065b..86c5b9c 100644 --- a/apps/server/src/ai/utils/eventFormatter.ts +++ b/apps/server/src/ai/utils/eventFormatter.ts @@ -1,30 +1,4 @@ -import { - CalendarEvent, - formatDate, - formatTime, - formatDateTime, -} from "@calchat/shared"; +import { formatDate, formatTime, formatDateTime } from "@calchat/shared"; -// Re-export for backwards compatibility +// Re-export from shared package for use in toolExecutor export { formatDate, formatTime, formatDateTime }; - -/** - * Format a list of events for display in the system prompt. - * Output is in German with date/time formatting. - */ -export function formatExistingEvents(events: CalendarEvent[]): string { - if (events.length === 0) { - return "Keine Termine vorhanden."; - } - - return events - .map((e) => { - const start = new Date(e.startTime); - const end = new Date(e.endTime); - const timeStr = `${formatTime(start)} - ${formatTime(end)}`; - const recurring = e.isRecurring ? " (wiederkehrend)" : ""; - const desc = e.description ? ` | ${e.description}` : ""; - return `- ${e.title} (ID: ${e.id}) | ${formatDate(start)} ${timeStr}${recurring}${desc}`; - }) - .join("\n"); -} diff --git a/apps/server/src/ai/utils/index.ts b/apps/server/src/ai/utils/index.ts index f2fc3e5..05a8a12 100644 --- a/apps/server/src/ai/utils/index.ts +++ b/apps/server/src/ai/utils/index.ts @@ -1,9 +1,4 @@ -export { - formatExistingEvents, - formatDate, - formatTime, - formatDateTime, -} from "./eventFormatter"; +export { formatDate, formatTime, formatDateTime } from "./eventFormatter"; export { buildSystemPrompt } from "./systemPrompt"; export { TOOL_DEFINITIONS, diff --git a/apps/server/src/ai/utils/systemPrompt.ts b/apps/server/src/ai/utils/systemPrompt.ts index 701e16c..3c1d129 100644 --- a/apps/server/src/ai/utils/systemPrompt.ts +++ b/apps/server/src/ai/utils/systemPrompt.ts @@ -1,5 +1,4 @@ import { AIContext } from "../../services/interfaces"; -import { formatExistingEvents } from "./eventFormatter"; /** * Build the system prompt for the AI assistant. @@ -15,8 +14,6 @@ export function buildSystemPrompt(context: AIContext): string { minute: "2-digit", }); - const eventsText = formatExistingEvents(context.existingEvents); - return `Du bist ein hilfreicher Kalender-Assistent für die App "CalChat". Du hilfst Benutzern beim Erstellen, Ändern und Löschen von Terminen. Antworte immer auf Deutsch. @@ -29,8 +26,16 @@ Wichtige Regeln: - Wenn der Benutzer einen Termin ändern will, nutze proposeUpdateEvent mit der Event-ID - Wenn der Benutzer einen Termin löschen will, nutze proposeDeleteEvent mit der Event-ID - Du kannst mehrere Event-Vorschläge in einer Antwort machen (z.B. für mehrere Termine auf einmal) -- Wenn der Benutzer nach seinen Terminen fragt, nutze die unten stehende Liste -- Nutze searchEvents um nach Terminen zu suchen, wenn du die genaue ID brauchst +- WICHTIG: Bei Terminen in der VERGANGENHEIT: Weise den Benutzer darauf hin und erstelle KEIN Event. Beispiel: "Das Datum liegt in der Vergangenheit. Meintest du vielleicht [nächstes Jahr]?" +- KRITISCH: Wenn ein Tool-Result eine ⚠️-Warnung enthält (z.B. "Zeitkonflikt mit..."), MUSST du diese dem Benutzer mitteilen! Ignoriere NIEMALS solche Warnungen! Beispiel: "An diesem Tag hast du bereits 'Jannes Geburtstag'. Soll ich den Termin trotzdem erstellen?" + +WICHTIG - Event-Abfragen: +- Du hast KEINEN vorgeladenen Kalender-Kontext! +- Nutze IMMER getEventsInRange um Events zu laden, wenn der Benutzer nach Terminen fragt +- Nutze searchEvents um nach Terminen per Titel zu suchen (gibt auch die Event-ID zurück) +- Beispiel: "Was habe ich heute?" → getEventsInRange für heute +- Beispiel: "Was habe ich diese Woche?" → getEventsInRange für die Woche +- Beispiel: "Wann ist der Zahnarzt?" → searchEvents mit "Zahnarzt" WICHTIG - Tool-Verwendung: - Du MUSST die proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent Tools verwenden, um Termine vorzuschlagen! @@ -50,18 +55,21 @@ WICHTIG - Wiederkehrende Termine (RRULE): - WICHTIG: Schreibe die RRULE NIEMALS in das description-Feld! Nutze IMMER das recurrenceRule-Feld! WICHTIG - Antwortformat: -- Halte deine Textantworten SEHR KURZ (1-2 Sätze maximal) -- Die Event-Details (Titel, Datum, Uhrzeit, Beschreibung) werden dem Benutzer automatisch in separaten Karten angezeigt -- Wiederhole NIEMALS die Event-Details im Text! Der Benutzer sieht sie bereits in den Karten - Verwende kontextbezogene Antworten in der GEGENWARTSFORM je nach Aktion: - Bei Termin-Erstellung: "Ich schlage folgenden Termin vor:" oder "Neuer Termin:" - Bei Termin-Änderung: "Ich schlage folgende Änderung vor:" oder "Änderung:" - Bei Termin-Löschung: "Ich schlage vor, diesen Termin zu löschen:" oder "Löschung:" - - Bei Übersichten: "Hier sind deine Termine:" - WICHTIG: Verwende NIEMALS Vergangenheitsform wie "Ich habe ... vorgeschlagen" - immer Gegenwartsform! -- Schlechte Beispiele: "Alles klar!" (zu unspezifisch), lange Listen mit Termin-Details im Text -- Bei Rückfragen oder wenn keine Termine erstellt werden, kannst du ausführlicher antworten -Existierende Termine des Benutzers: -${eventsText}`; +WICHTIG - Unterscheide zwischen PROPOSALS und ABFRAGEN: +1. Bei PROPOSALS (proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent): + - Halte deine Textantworten SEHR KURZ (1-2 Sätze) + - Die Event-Details werden automatisch in Karten angezeigt + - Wiederhole NICHT die Details im Text +2. Bei ABFRAGEN (searchEvents, getEventsInRange, oder Fragen zu existierenden Terminen): + - Du MUSST die gefundenen Termine im Text nennen! + - Liste die relevanten Termine mit Titel, Datum und Uhrzeit auf + - NIEMALS Event-IDs dem Benutzer zeigen! Die IDs sind nur für dich intern + - Wenn keine Termine gefunden wurden, sage das explizit (z.B. "In diesem Zeitraum hast du keine Termine.") + - Beispiel: "Heute hast du: Zahnarzt um 10:00 Uhr, Meeting um 14:00 Uhr."`; } diff --git a/apps/server/src/ai/utils/toolDefinitions.ts b/apps/server/src/ai/utils/toolDefinitions.ts index 553f56c..a538510 100644 --- a/apps/server/src/ai/utils/toolDefinitions.ts +++ b/apps/server/src/ai/utils/toolDefinitions.ts @@ -179,4 +179,23 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ required: ["query"], }, }, + { + name: "getEventsInRange", + description: + "Load events from a specific date range. Use this when the user asks about a time period beyond the default 4 weeks (e.g., 'birthdays in the next 6 months', 'what do I have planned for summer').", + parameters: { + type: "object", + properties: { + startDate: { + type: "string", + description: "Start date as ISO string (YYYY-MM-DD)", + }, + endDate: { + type: "string", + description: "End date as ISO string (YYYY-MM-DD)", + }, + }, + required: ["startDate", "endDate"], + }, + }, ]; diff --git a/apps/server/src/ai/utils/toolExecutor.ts b/apps/server/src/ai/utils/toolExecutor.ts index fbdae98..d10ca19 100644 --- a/apps/server/src/ai/utils/toolExecutor.ts +++ b/apps/server/src/ai/utils/toolExecutor.ts @@ -8,6 +8,18 @@ import { import { AIContext } from "../../services/interfaces"; import { formatDate, formatTime, formatDateTime } from "./eventFormatter"; +/** + * Check if two time ranges overlap. + */ +function hasTimeOverlap( + start1: Date, + end1: Date, + start2: Date, + end2: Date, +): boolean { + return start1 < end2 && end1 > start2; +} + /** * Proposed change without ID - ID is added by GPTAdapter when collecting proposals */ @@ -24,12 +36,13 @@ export interface ToolResult { /** * Execute a tool call and return the result. * This function is provider-agnostic and can be used with any LLM. + * Async to support tools that need to fetch data (e.g., getEventsInRange). */ -export function executeToolCall( +export async function executeToolCall( name: string, args: Record, context: AIContext, -): ToolResult { +): Promise { switch (name) { case "getDay": { const date = getDay( @@ -62,20 +75,52 @@ export function executeToolCall( const dateStr = formatDate(event.startTime); const startStr = formatTime(event.startTime); const endStr = formatTime(event.endTime); + + // Check for conflicts - fetch events for the specific day + const dayStart = new Date(event.startTime); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(dayStart); + dayEnd.setDate(dayStart.getDate() + 1); + + const dayEvents = await context.fetchEventsInRange(dayStart, dayEnd); + + // Use occurrenceStart/occurrenceEnd for expanded recurring events + const conflicts = dayEvents.filter((e) => + hasTimeOverlap( + event.startTime, + event.endTime, + new Date(e.occurrenceStart), + new Date(e.occurrenceEnd), + ), + ); + + // Build conflict warning if any + let conflictWarning = ""; + if (conflicts.length > 0) { + const conflictNames = conflicts.map((c) => `"${c.title}"`).join(", "); + conflictWarning = `\n⚠️ ACHTUNG: Zeitkonflikt mit ${conflictNames}!`; + } + return { - content: `Event-Vorschlag erstellt: "${event.title}" am ${dateStr} von ${startStr} bis ${endStr} Uhr`, + content: `Event-Vorschlag erstellt: "${event.title}" am ${dateStr} von ${startStr} bis ${endStr} Uhr${conflictWarning}`, proposedChange: { action: "create", event, + conflictingEvents: + conflicts.length > 0 + ? conflicts.map((c) => ({ + title: c.title, + startTime: new Date(c.occurrenceStart), + endTime: new Date(c.occurrenceEnd), + })) + : undefined, }, }; } case "proposeUpdateEvent": { const eventId = args.eventId as string; - const existingEvent = context.existingEvents.find( - (e) => e.id === eventId, - ); + const existingEvent = await context.fetchEventById(eventId); if (!existingEvent) { return { content: `Event mit ID ${eventId} nicht gefunden.` }; @@ -116,9 +161,7 @@ export function executeToolCall( const eventId = args.eventId as string; const deleteMode = (args.deleteMode as RecurringDeleteMode) || "all"; const occurrenceDate = args.occurrenceDate as string | undefined; - const existingEvent = context.existingEvents.find( - (e) => e.id === eventId, - ); + const existingEvent = await context.fetchEventById(eventId); if (!existingEvent) { return { content: `Event mit ID ${eventId} nicht gefunden.` }; @@ -162,25 +205,46 @@ export function executeToolCall( } case "searchEvents": { - const query = (args.query as string).toLowerCase(); - const matches = context.existingEvents.filter((e) => - e.title.toLowerCase().includes(query), - ); + const query = args.query as string; + const matches = await context.searchEvents(query); if (matches.length === 0) { - return { content: `Keine Termine mit "${args.query}" gefunden.` }; + return { content: `Keine Termine mit "${query}" gefunden.` }; } const results = matches .map((e) => { const start = new Date(e.startTime); - return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr`; + const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : ""; + return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`; }) .join("\n"); return { content: `Gefundene Termine:\n${results}` }; } + case "getEventsInRange": { + const startDate = new Date(args.startDate as string); + const endDate = new Date(args.endDate as string); + const events = await context.fetchEventsInRange(startDate, endDate); + + if (events.length === 0) { + return { content: "Keine Termine in diesem Zeitraum." }; + } + + const eventsText = events + .map((e) => { + const start = new Date(e.startTime); + const recurrenceInfo = e.recurrenceRule ? " (wiederkehrend)" : ""; + return `- ${e.title} (ID: ${e.id}) am ${formatDate(start)} um ${formatTime(start)} Uhr${recurrenceInfo}`; + }) + .join("\n"); + + return { + content: `Termine von ${formatDate(startDate)} bis ${formatDate(endDate)}:\n${eventsText}`, + }; + } + default: return { content: `Unbekannte Funktion: ${name}` }; } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index d3c66c8..b63571c 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -96,8 +96,10 @@ app.post("/api/ai/test", async (req, res) => { const result = await aiProvider.processMessage(message, { userId: "test-user", conversationHistory: [], - existingEvents: [], currentDate: new Date(), + fetchEventsInRange: async () => [], + searchEvents: async () => [], + fetchEventById: async () => null, }); res.json(result); } catch (error) { diff --git a/apps/server/src/logging/Logged.ts b/apps/server/src/logging/Logged.ts index 2f09bdc..8e6ea6b 100644 --- a/apps/server/src/logging/Logged.ts +++ b/apps/server/src/logging/Logged.ts @@ -4,7 +4,7 @@ import { createLogger } from "./logger"; * Summarize args for logging to avoid huge log entries. * - Arrays: show length only * - Long strings: truncate - * - Objects with conversationHistory/existingEvents: summarize + * - Objects with conversationHistory: summarize */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function summarizeArgs(args: any[]): any[] { @@ -31,8 +31,6 @@ function summarizeValue(value: any, depth = 0): any { for (const [key, val] of Object.entries(value)) { if (key === "conversationHistory" && Array.isArray(val)) { summarized[key] = `[${val.length} messages]`; - } else if (key === "existingEvents" && Array.isArray(val)) { - summarized[key] = `[${val.length} events]`; } else if (key === "proposedChanges" && Array.isArray(val)) { // Log full proposedChanges for debugging AI issues summarized[key] = val.map((p) => summarizeValue(p, depth + 1)); diff --git a/apps/server/src/repositories/mongo/MongoChatRepository.ts b/apps/server/src/repositories/mongo/MongoChatRepository.ts index 9425acb..0bd839e 100644 --- a/apps/server/src/repositories/mongo/MongoChatRepository.ts +++ b/apps/server/src/repositories/mongo/MongoChatRepository.ts @@ -5,6 +5,7 @@ import { CreateEventDTO, GetMessagesOptions, UpdateMessageDTO, + ConflictingEvent, } from "@calchat/shared"; import { ChatRepository } from "../../services/interfaces"; import { Logged } from "../../logging"; @@ -25,12 +26,20 @@ export class MongoChatRepository implements ChatRepository { return conversation.toJSON() as unknown as Conversation; } + async getConversationById( + conversationId: string, + ): Promise { + const conversation = await ConversationModel.findById(conversationId); + return conversation + ? (conversation.toJSON() as unknown as Conversation) + : null; + } + // Messages (cursor-based pagination) async getMessages( conversationId: string, options?: GetMessagesOptions, ): Promise { - const limit = options?.limit ?? 20; const query: Record = { conversationId }; // Cursor: load messages before this ID (for "load more" scrolling up) @@ -39,9 +48,12 @@ export class MongoChatRepository implements ChatRepository { } // Fetch newest first, then reverse for chronological order - const docs = await ChatMessageModel.find(query) - .sort({ _id: -1 }) - .limit(limit); + // Only apply limit if explicitly specified (no default - load all messages) + let queryBuilder = ChatMessageModel.find(query).sort({ _id: -1 }); + if (options?.limit) { + queryBuilder = queryBuilder.limit(options.limit); + } + const docs = await queryBuilder; return docs.reverse().map((doc) => doc.toJSON() as unknown as ChatMessage); } @@ -88,12 +100,28 @@ export class MongoChatRepository implements ChatRepository { messageId: string, proposalId: string, event: CreateEventDTO, + conflictingEvents?: ConflictingEvent[], ): Promise { + // Always set both fields - use empty array when no conflicts + // (MongoDB has issues combining $set and $unset on positional operator) + const setFields: Record = { + "proposedChanges.$.event": event, + "proposedChanges.$.conflictingEvents": + conflictingEvents && conflictingEvents.length > 0 + ? conflictingEvents + : [], + }; + const doc = await ChatMessageModel.findOneAndUpdate( { _id: messageId, "proposedChanges.id": proposalId }, - { $set: { "proposedChanges.$.event": event } }, + { $set: setFields }, { new: true }, ); return doc ? (doc.toJSON() as unknown as ChatMessage) : null; } + + async getMessageById(messageId: string): Promise { + const doc = await ChatMessageModel.findById(messageId); + return doc ? (doc.toJSON() as unknown as ChatMessage) : null; + } } diff --git a/apps/server/src/repositories/mongo/MongoEventRepository.ts b/apps/server/src/repositories/mongo/MongoEventRepository.ts index a3efc2d..e0be956 100644 --- a/apps/server/src/repositories/mongo/MongoEventRepository.ts +++ b/apps/server/src/repositories/mongo/MongoEventRepository.ts @@ -28,6 +28,14 @@ export class MongoEventRepository implements EventRepository { return events.map((e) => e.toJSON() as unknown as CalendarEvent); } + async searchByTitle(userId: string, query: string): Promise { + const events = await EventModel.find({ + userId, + title: { $regex: query, $options: "i" }, + }).sort({ startTime: 1 }); + return events.map((e) => e.toJSON() as unknown as CalendarEvent); + } + async create(userId: string, data: CreateEventDTO): Promise { const event = await EventModel.create({ userId, ...data }); // NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field diff --git a/apps/server/src/repositories/mongo/models/ChatModel.ts b/apps/server/src/repositories/mongo/models/ChatModel.ts index 92d69c9..18d0e7a 100644 --- a/apps/server/src/repositories/mongo/models/ChatModel.ts +++ b/apps/server/src/repositories/mongo/models/ChatModel.ts @@ -5,6 +5,7 @@ import { CreateEventDTO, UpdateEventDTO, ProposedEventChange, + ConflictingEvent, } from "@calchat/shared"; import { IdVirtual } from "./types"; @@ -41,6 +42,15 @@ const UpdatesSchema = new Schema( { _id: false }, ); +const ConflictingEventSchema = new Schema( + { + title: { type: String, required: true }, + startTime: { type: Date, required: true }, + endTime: { type: Date, required: true }, + }, + { _id: false }, +); + const ProposedChangeSchema = new Schema( { id: { type: String, required: true }, @@ -61,6 +71,7 @@ const ProposedChangeSchema = new Schema( enum: ["single", "future", "all"], }, occurrenceDate: { type: String }, + conflictingEvents: { type: [ConflictingEventSchema] }, }, { _id: false }, ); diff --git a/apps/server/src/services/ChatService.ts b/apps/server/src/services/ChatService.ts index 4391383..a6ef096 100644 --- a/apps/server/src/services/ChatService.ts +++ b/apps/server/src/services/ChatService.ts @@ -9,8 +9,8 @@ import { CreateEventDTO, UpdateEventDTO, EventAction, - CreateMessageDTO, RecurringDeleteMode, + ConflictingEvent, } from "@calchat/shared"; import { ChatRepository, EventRepository, AIProvider } from "./interfaces"; import { EventService } from "./EventService"; @@ -570,7 +570,6 @@ export class ChatService { responseIndex++; } else { // Production mode: use real AI - const events = await this.eventRepo.findByUserId(userId); const history = await this.chatRepo.getMessages(conversationId, { limit: 20, }); @@ -578,8 +577,16 @@ export class ChatService { response = await this.aiProvider.processMessage(data.content, { userId, conversationHistory: history, - existingEvents: events, currentDate: new Date(), + fetchEventsInRange: async (start, end) => { + return this.eventService.getByDateRange(userId, start, end); + }, + searchEvents: async (query) => { + return this.eventRepo.searchByTitle(userId, query); + }, + fetchEventById: async (eventId) => { + return this.eventService.getById(eventId, userId); + }, }); } @@ -713,6 +720,56 @@ export class ChatService { proposalId: string, event: CreateEventDTO, ): Promise { - return this.chatRepo.updateProposalEvent(messageId, proposalId, event); + // Get the message to find the conversation + const message = await this.chatRepo.getMessageById(messageId); + if (!message) { + return null; + } + + // Get the conversation to find the userId + const conversation = await this.chatRepo.getConversationById( + message.conversationId, + ); + if (!conversation) { + return null; + } + const userId = conversation.userId; + + // Get event times + const eventStart = new Date(event.startTime); + const eventEnd = new Date(event.endTime); + + // Get day range for conflict checking + const dayStart = new Date(eventStart); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(dayStart); + dayEnd.setDate(dayStart.getDate() + 1); + + // Fetch events for the day + const dayEvents = await this.eventService.getByDateRange( + userId, + dayStart, + dayEnd, + ); + + // Check for time overlaps (use occurrenceStart/End for expanded recurring events) + const conflicts: ConflictingEvent[] = dayEvents + .filter( + (e) => + new Date(e.occurrenceStart) < eventEnd && + new Date(e.occurrenceEnd) > eventStart, + ) + .map((e) => ({ + title: e.title, + startTime: new Date(e.occurrenceStart), + endTime: new Date(e.occurrenceEnd), + })); + + return this.chatRepo.updateProposalEvent( + messageId, + proposalId, + event, + conflicts.length > 0 ? conflicts : undefined, + ); } } diff --git a/apps/server/src/services/interfaces/AIProvider.ts b/apps/server/src/services/interfaces/AIProvider.ts index 165bf90..870816c 100644 --- a/apps/server/src/services/interfaces/AIProvider.ts +++ b/apps/server/src/services/interfaces/AIProvider.ts @@ -1,14 +1,21 @@ import { - CalendarEvent, ChatMessage, ProposedEventChange, + ExpandedEvent, + CalendarEvent, } from "@calchat/shared"; export interface AIContext { userId: string; conversationHistory: ChatMessage[]; - existingEvents: CalendarEvent[]; currentDate: Date; + // Callback to load events from a specific date range + // Returns ExpandedEvent[] with occurrenceStart/occurrenceEnd for recurring events + fetchEventsInRange: (startDate: Date, endDate: Date) => Promise; + // Callback to search events by title + searchEvents: (query: string) => Promise; + // Callback to fetch a single event by ID + fetchEventById: (eventId: string) => Promise; } export interface AIResponse { diff --git a/apps/server/src/services/interfaces/ChatRepository.ts b/apps/server/src/services/interfaces/ChatRepository.ts index 0dcee01..5cc836e 100644 --- a/apps/server/src/services/interfaces/ChatRepository.ts +++ b/apps/server/src/services/interfaces/ChatRepository.ts @@ -5,11 +5,13 @@ import { CreateEventDTO, GetMessagesOptions, UpdateMessageDTO, + ConflictingEvent, } from "@calchat/shared"; export interface ChatRepository { // Conversations getConversationsByUser(userId: string): Promise; + getConversationById(conversationId: string): Promise; createConversation(userId: string): Promise; // Messages (cursor-based pagination) @@ -38,5 +40,8 @@ export interface ChatRepository { messageId: string, proposalId: string, event: CreateEventDTO, + conflictingEvents?: ConflictingEvent[], ): Promise; + + getMessageById(messageId: string): Promise; } diff --git a/apps/server/src/services/interfaces/EventRepository.ts b/apps/server/src/services/interfaces/EventRepository.ts index 160130d..32cff09 100644 --- a/apps/server/src/services/interfaces/EventRepository.ts +++ b/apps/server/src/services/interfaces/EventRepository.ts @@ -8,6 +8,7 @@ export interface EventRepository { startDate: Date, endDate: Date, ): Promise; + searchByTitle(userId: string, query: string): Promise; create(userId: string, data: CreateEventDTO): Promise; update(id: string, data: UpdateEventDTO): Promise; delete(id: string): Promise; diff --git a/packages/shared/src/models/ChatMessage.ts b/packages/shared/src/models/ChatMessage.ts index d1cd08f..93c6c81 100644 --- a/packages/shared/src/models/ChatMessage.ts +++ b/packages/shared/src/models/ChatMessage.ts @@ -10,6 +10,12 @@ export type EventAction = "create" | "update" | "delete"; export type RespondedAction = "confirm" | "reject"; +export interface ConflictingEvent { + title: string; + startTime: Date; + endTime: Date; +} + export interface ProposedEventChange { id: string; // Unique ID for each proposal action: EventAction; @@ -19,6 +25,7 @@ export interface ProposedEventChange { respondedAction?: RespondedAction; // User's response to this specific proposal deleteMode?: RecurringDeleteMode; // For recurring event deletion occurrenceDate?: string; // ISO date string of specific occurrence for single/future delete + conflictingEvents?: ConflictingEvent[]; // Overlapping events for conflict warnings } export interface ChatMessage {