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
This commit is contained in:
2026-02-02 22:44:08 +01:00
parent 387bb2d1ee
commit 1092ff2648
19 changed files with 367 additions and 119 deletions

View File

@@ -266,10 +266,10 @@ src/
│ ├── index.ts # Re-exports GPTAdapter │ ├── index.ts # Re-exports GPTAdapter
│ └── utils/ # Shared AI utilities (provider-agnostic) │ └── utils/ # Shared AI utilities (provider-agnostic)
│ ├── index.ts # Re-exports │ ├── 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 │ ├── systemPrompt.ts # buildSystemPrompt() - German calendar assistant prompt
│ ├── toolDefinitions.ts # TOOL_DEFINITIONS - provider-agnostic tool specs │ ├── 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/ ├── utils/
│ ├── jwt.ts # signToken(), verifyToken() - NOT USED YET (no JWT) │ ├── jwt.ts # signToken(), verifyToken() - NOT USED YET (no JWT)
│ ├── password.ts # hash(), compare() using bcrypt │ ├── password.ts # hash(), compare() using bcrypt
@@ -325,10 +325,12 @@ src/
- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule?, exceptionDates? - `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule?, exceptionDates?
- `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances) - `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances)
- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges? - `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 - Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject
- `respondedAction` tracks user response per proposal (not per message) - `respondedAction` tracks user response per proposal (not per message)
- `deleteMode` ('single' | 'future' | 'all') and `occurrenceDate` for recurring event deletion - `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 - `RecurringDeleteMode`: 'single' | 'future' | 'all' - delete modes for recurring events
- `DeleteRecurringEventDTO`: mode, occurrenceDate? - DTO for recurring event deletion - `DeleteRecurringEventDTO`: mode, occurrenceDate? - DTO for recurring event deletion
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading) - `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
@@ -342,6 +344,39 @@ src/
- `Day`: "Monday" | "Tuesday" | ... | "Sunday" - `Day`: "Monday" | "Tuesday" | ... | "Sunday"
- `Month`: "January" | "February" | ... | "December" - `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<ExpandedEvent[]>;
searchEvents: (query: string) => Promise<CalendarEvent[]>;
fetchEventById: (eventId: string) => Promise<CalendarEvent | null>;
}
```
**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 ### Database Abstraction
The repository pattern allows swapping databases: 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:** **Log Summarization:**
The `@Logged` decorator automatically summarizes large arguments to keep logs readable: The `@Logged` decorator automatically summarizes large arguments to keep logs readable:
- `conversationHistory``"[5 messages]"` - `conversationHistory``"[5 messages]"`
- `existingEvents``"[3 events]"`
- `proposedChanges` → logged in full (for debugging AI issues) - `proposedChanges` → logged in full (for debugging AI issues)
- Long strings (>100 chars) → truncated - Long strings (>100 chars) → truncated
- Arrays → `"[Array(n)]"` - 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) - `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse, updateProposalEvent)
- `ChatRepository` interface: updateMessage(), updateProposalResponse(), updateProposalEvent() for per-proposal tracking - `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 - `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/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor)
- `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/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 for adding UNTIL or modifying recurrence - `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 - `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 - `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
- All repositories and GPTAdapter decorated with @Logged for automatic method logging - 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 - `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 - `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) - `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 - `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 - `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.) - `Themes.tsx`: Theme definitions with THEMES object (defaultLight, defaultDark) including all color tokens (textPrimary, borderPrimary, eventIndicator, secondaryBg, shadowColor, etc.)

View File

@@ -22,9 +22,6 @@ import { EventService, ChatService } from "../services";
import { buildRRule, CreateEventDTO } from "@calchat/shared"; import { buildRRule, CreateEventDTO } from "@calchat/shared";
import { useChatStore } from "../stores"; import { useChatStore } from "../stores";
// Direct store access for getting current state in callbacks
const getChatStoreState = () => useChatStore.getState();
type EditEventTextFieldProps = { type EditEventTextFieldProps = {
titel: string; titel: string;
text?: string; text?: string;
@@ -443,31 +440,25 @@ const EditEventScreen = () => {
: undefined, : 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) { if (mode === "chat" && proposalContext) {
try { try {
const context = JSON.parse(proposalContext) as ProposalContext; const context = JSON.parse(proposalContext) as ProposalContext;
// Update locally in ChatStore // Persist to server - returns updated message with recalculated conflictingEvents
const currentMessages = getChatStoreState().messages; const updatedMessage = await ChatService.updateProposalEvent(
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(
context.messageId, context.messageId,
context.proposalId, context.proposalId,
eventObject, eventObject,
); );
// Update local ChatStore with server response (includes updated conflicts)
if (updatedMessage?.proposedChanges) {
updateMessage(context.messageId, {
proposedChanges: updatedMessage.proposedChanges,
});
}
router.back(); router.back();
} catch (error) { } catch (error) {
console.error("Failed to update proposal:", error); console.error("Failed to update proposal:", error);

View File

@@ -1,6 +1,6 @@
import { View, Text, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { Feather } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { ProposedEventChange, formatDate } from "@calchat/shared"; import { ProposedEventChange, formatDate, formatTime } from "@calchat/shared";
import { rrulestr } from "rrule"; import { rrulestr } from "rrule";
import { useThemeStore } from "../stores/ThemeStore"; import { useThemeStore } from "../stores/ThemeStore";
import { EventCardBase } from "./EventCardBase"; import { EventCardBase } from "./EventCardBase";
@@ -143,6 +143,29 @@ export const ProposedEventCard = ({
</Text> </Text>
</View> </View>
)} )}
{/* Show conflicting events warning */}
{proposedChange.conflictingEvents &&
proposedChange.conflictingEvents.length > 0 && (
<View className="mb-2">
{proposedChange.conflictingEvents.map((conflict, index) => (
<View key={index} className="flex-row items-center mt-1">
<Ionicons
name="alert-circle"
size={16}
color={theme.rejectButton}
style={{ marginRight: 8 }}
/>
<Text
style={{ color: theme.rejectButton }}
className="text-sm flex-1"
>
Konflikt: {conflict.title} ({formatTime(conflict.startTime)}{" "}
- {formatTime(conflict.endTime)})
</Text>
</View>
))}
</View>
)}
<ActionButtons <ActionButtons
isDisabled={isDisabled} isDisabled={isDisabled}
respondedAction={proposedChange.respondedAction} respondedAction={proposedChange.respondedAction}

View File

@@ -8,6 +8,11 @@ import {
ToolDefinition, ToolDefinition,
} from "./utils"; } from "./utils";
import { Logged } from "../logging"; import { Logged } from "../logging";
import {
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionTool,
} from "openai/resources/chat/completions/completions";
/** /**
* Convert tool definitions to OpenAI format. * Convert tool definitions to OpenAI format.
@@ -29,7 +34,7 @@ function toOpenAITools(
export class GPTAdapter implements AIProvider { export class GPTAdapter implements AIProvider {
private client: OpenAI; private client: OpenAI;
private model: string; private model: string;
private tools: OpenAI.Chat.Completions.ChatCompletionTool[]; private tools: ChatCompletionTool[];
constructor(apiKey?: string, model: string = "gpt-5-mini") { constructor(apiKey?: string, model: string = "gpt-5-mini") {
this.client = new OpenAI({ this.client = new OpenAI({
@@ -46,7 +51,7 @@ export class GPTAdapter implements AIProvider {
const systemPrompt = buildSystemPrompt(context); const systemPrompt = buildSystemPrompt(context);
// Build messages array with conversation history // Build messages array with conversation history
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ const messages: ChatCompletionMessageParam[] = [
{ role: "developer", content: systemPrompt }, { role: "developer", content: systemPrompt },
]; ];
@@ -87,17 +92,21 @@ export class GPTAdapter implements AIProvider {
}; };
} }
// Process tool calls // Process all tool calls and collect results
const toolResults: Array<{
toolCall: ChatCompletionMessageToolCall;
content: string;
}> = [];
for (const toolCall of assistantMessage.tool_calls) { for (const toolCall of assistantMessage.tool_calls) {
// Skip non-function tool calls
if (toolCall.type !== "function") continue; if (toolCall.type !== "function") continue;
const { name, arguments: argsRaw } = toolCall.function; const { name, arguments: argsRaw } = toolCall.function;
const args = JSON.parse(argsRaw); 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) { if (result.proposedChange) {
proposedChanges.push({ proposedChanges.push({
id: `proposal-${proposalIndex++}`, id: `proposal-${proposalIndex++}`,
@@ -105,17 +114,22 @@ export class GPTAdapter implements AIProvider {
}); });
} }
// Add assistant message with tool call toolResults.push({ toolCall, content: result.content });
messages.push({ }
role: "assistant",
tool_calls: [toolCall],
});
// 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({ messages.push({
role: "tool", role: "tool",
tool_call_id: toolCall.id, tool_call_id: toolCall.id,
content: result.content, content,
}); });
} }
} }

View File

@@ -1,30 +1,4 @@
import { import { formatDate, formatTime, formatDateTime } from "@calchat/shared";
CalendarEvent,
formatDate,
formatTime,
formatDateTime,
} from "@calchat/shared";
// Re-export for backwards compatibility // Re-export from shared package for use in toolExecutor
export { formatDate, formatTime, formatDateTime }; 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");
}

View File

@@ -1,9 +1,4 @@
export { export { formatDate, formatTime, formatDateTime } from "./eventFormatter";
formatExistingEvents,
formatDate,
formatTime,
formatDateTime,
} from "./eventFormatter";
export { buildSystemPrompt } from "./systemPrompt"; export { buildSystemPrompt } from "./systemPrompt";
export { export {
TOOL_DEFINITIONS, TOOL_DEFINITIONS,

View File

@@ -1,5 +1,4 @@
import { AIContext } from "../../services/interfaces"; import { AIContext } from "../../services/interfaces";
import { formatExistingEvents } from "./eventFormatter";
/** /**
* Build the system prompt for the AI assistant. * Build the system prompt for the AI assistant.
@@ -15,8 +14,6 @@ export function buildSystemPrompt(context: AIContext): string {
minute: "2-digit", minute: "2-digit",
}); });
const eventsText = formatExistingEvents(context.existingEvents);
return `Du bist ein hilfreicher Kalender-Assistent für die App "CalChat". return `Du bist ein hilfreicher Kalender-Assistent für die App "CalChat".
Du hilfst Benutzern beim Erstellen, Ändern und Löschen von Terminen. Du hilfst Benutzern beim Erstellen, Ändern und Löschen von Terminen.
Antworte immer auf Deutsch. 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 ändern will, nutze proposeUpdateEvent mit der Event-ID
- Wenn der Benutzer einen Termin löschen will, nutze proposeDeleteEvent 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) - 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 - 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]?"
- Nutze searchEvents um nach Terminen zu suchen, wenn du die genaue ID brauchst - 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: WICHTIG - Tool-Verwendung:
- Du MUSST die proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent Tools verwenden, um Termine vorzuschlagen! - 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: Schreibe die RRULE NIEMALS in das description-Feld! Nutze IMMER das recurrenceRule-Feld!
WICHTIG - Antwortformat: 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: - Verwende kontextbezogene Antworten in der GEGENWARTSFORM je nach Aktion:
- Bei Termin-Erstellung: "Ich schlage folgenden Termin vor:" oder "Neuer Termin:" - Bei Termin-Erstellung: "Ich schlage folgenden Termin vor:" oder "Neuer Termin:"
- Bei Termin-Änderung: "Ich schlage folgende Änderung vor:" oder "Änderung:" - 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 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! - 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: WICHTIG - Unterscheide zwischen PROPOSALS und ABFRAGEN:
${eventsText}`; 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."`;
} }

View File

@@ -179,4 +179,23 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
required: ["query"], 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"],
},
},
]; ];

View File

@@ -8,6 +8,18 @@ import {
import { AIContext } from "../../services/interfaces"; import { AIContext } from "../../services/interfaces";
import { formatDate, formatTime, formatDateTime } from "./eventFormatter"; 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 * 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. * Execute a tool call and return the result.
* This function is provider-agnostic and can be used with any LLM. * 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, name: string,
args: Record<string, unknown>, args: Record<string, unknown>,
context: AIContext, context: AIContext,
): ToolResult { ): Promise<ToolResult> {
switch (name) { switch (name) {
case "getDay": { case "getDay": {
const date = getDay( const date = getDay(
@@ -62,20 +75,52 @@ export function executeToolCall(
const dateStr = formatDate(event.startTime); const dateStr = formatDate(event.startTime);
const startStr = formatTime(event.startTime); const startStr = formatTime(event.startTime);
const endStr = formatTime(event.endTime); 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 { 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: { proposedChange: {
action: "create", action: "create",
event, event,
conflictingEvents:
conflicts.length > 0
? conflicts.map((c) => ({
title: c.title,
startTime: new Date(c.occurrenceStart),
endTime: new Date(c.occurrenceEnd),
}))
: undefined,
}, },
}; };
} }
case "proposeUpdateEvent": { case "proposeUpdateEvent": {
const eventId = args.eventId as string; const eventId = args.eventId as string;
const existingEvent = context.existingEvents.find( const existingEvent = await context.fetchEventById(eventId);
(e) => e.id === eventId,
);
if (!existingEvent) { if (!existingEvent) {
return { content: `Event mit ID ${eventId} nicht gefunden.` }; return { content: `Event mit ID ${eventId} nicht gefunden.` };
@@ -116,9 +161,7 @@ export function executeToolCall(
const eventId = args.eventId as string; const eventId = args.eventId as string;
const deleteMode = (args.deleteMode as RecurringDeleteMode) || "all"; const deleteMode = (args.deleteMode as RecurringDeleteMode) || "all";
const occurrenceDate = args.occurrenceDate as string | undefined; const occurrenceDate = args.occurrenceDate as string | undefined;
const existingEvent = context.existingEvents.find( const existingEvent = await context.fetchEventById(eventId);
(e) => e.id === eventId,
);
if (!existingEvent) { if (!existingEvent) {
return { content: `Event mit ID ${eventId} nicht gefunden.` }; return { content: `Event mit ID ${eventId} nicht gefunden.` };
@@ -162,25 +205,46 @@ export function executeToolCall(
} }
case "searchEvents": { case "searchEvents": {
const query = (args.query as string).toLowerCase(); const query = args.query as string;
const matches = context.existingEvents.filter((e) => const matches = await context.searchEvents(query);
e.title.toLowerCase().includes(query),
);
if (matches.length === 0) { if (matches.length === 0) {
return { content: `Keine Termine mit "${args.query}" gefunden.` }; return { content: `Keine Termine mit "${query}" gefunden.` };
} }
const results = matches const results = matches
.map((e) => { .map((e) => {
const start = new Date(e.startTime); 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"); .join("\n");
return { content: `Gefundene Termine:\n${results}` }; 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: default:
return { content: `Unbekannte Funktion: ${name}` }; return { content: `Unbekannte Funktion: ${name}` };
} }

View File

@@ -96,8 +96,10 @@ app.post("/api/ai/test", async (req, res) => {
const result = await aiProvider.processMessage(message, { const result = await aiProvider.processMessage(message, {
userId: "test-user", userId: "test-user",
conversationHistory: [], conversationHistory: [],
existingEvents: [],
currentDate: new Date(), currentDate: new Date(),
fetchEventsInRange: async () => [],
searchEvents: async () => [],
fetchEventById: async () => null,
}); });
res.json(result); res.json(result);
} catch (error) { } catch (error) {

View File

@@ -4,7 +4,7 @@ import { createLogger } from "./logger";
* Summarize args for logging to avoid huge log entries. * Summarize args for logging to avoid huge log entries.
* - Arrays: show length only * - Arrays: show length only
* - Long strings: truncate * - Long strings: truncate
* - Objects with conversationHistory/existingEvents: summarize * - Objects with conversationHistory: summarize
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function summarizeArgs(args: any[]): 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)) { for (const [key, val] of Object.entries(value)) {
if (key === "conversationHistory" && Array.isArray(val)) { if (key === "conversationHistory" && Array.isArray(val)) {
summarized[key] = `[${val.length} messages]`; summarized[key] = `[${val.length} messages]`;
} else if (key === "existingEvents" && Array.isArray(val)) {
summarized[key] = `[${val.length} events]`;
} else if (key === "proposedChanges" && Array.isArray(val)) { } else if (key === "proposedChanges" && Array.isArray(val)) {
// Log full proposedChanges for debugging AI issues // Log full proposedChanges for debugging AI issues
summarized[key] = val.map((p) => summarizeValue(p, depth + 1)); summarized[key] = val.map((p) => summarizeValue(p, depth + 1));

View File

@@ -5,6 +5,7 @@ import {
CreateEventDTO, CreateEventDTO,
GetMessagesOptions, GetMessagesOptions,
UpdateMessageDTO, UpdateMessageDTO,
ConflictingEvent,
} from "@calchat/shared"; } from "@calchat/shared";
import { ChatRepository } from "../../services/interfaces"; import { ChatRepository } from "../../services/interfaces";
import { Logged } from "../../logging"; import { Logged } from "../../logging";
@@ -25,12 +26,20 @@ export class MongoChatRepository implements ChatRepository {
return conversation.toJSON() as unknown as Conversation; return conversation.toJSON() as unknown as Conversation;
} }
async getConversationById(
conversationId: string,
): Promise<Conversation | null> {
const conversation = await ConversationModel.findById(conversationId);
return conversation
? (conversation.toJSON() as unknown as Conversation)
: null;
}
// Messages (cursor-based pagination) // Messages (cursor-based pagination)
async getMessages( async getMessages(
conversationId: string, conversationId: string,
options?: GetMessagesOptions, options?: GetMessagesOptions,
): Promise<ChatMessage[]> { ): Promise<ChatMessage[]> {
const limit = options?.limit ?? 20;
const query: Record<string, unknown> = { conversationId }; const query: Record<string, unknown> = { conversationId };
// Cursor: load messages before this ID (for "load more" scrolling up) // 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 // Fetch newest first, then reverse for chronological order
const docs = await ChatMessageModel.find(query) // Only apply limit if explicitly specified (no default - load all messages)
.sort({ _id: -1 }) let queryBuilder = ChatMessageModel.find(query).sort({ _id: -1 });
.limit(limit); if (options?.limit) {
queryBuilder = queryBuilder.limit(options.limit);
}
const docs = await queryBuilder;
return docs.reverse().map((doc) => doc.toJSON() as unknown as ChatMessage); return docs.reverse().map((doc) => doc.toJSON() as unknown as ChatMessage);
} }
@@ -88,12 +100,28 @@ export class MongoChatRepository implements ChatRepository {
messageId: string, messageId: string,
proposalId: string, proposalId: string,
event: CreateEventDTO, event: CreateEventDTO,
conflictingEvents?: ConflictingEvent[],
): Promise<ChatMessage | null> { ): Promise<ChatMessage | null> {
// Always set both fields - use empty array when no conflicts
// (MongoDB has issues combining $set and $unset on positional operator)
const setFields: Record<string, unknown> = {
"proposedChanges.$.event": event,
"proposedChanges.$.conflictingEvents":
conflictingEvents && conflictingEvents.length > 0
? conflictingEvents
: [],
};
const doc = await ChatMessageModel.findOneAndUpdate( const doc = await ChatMessageModel.findOneAndUpdate(
{ _id: messageId, "proposedChanges.id": proposalId }, { _id: messageId, "proposedChanges.id": proposalId },
{ $set: { "proposedChanges.$.event": event } }, { $set: setFields },
{ new: true }, { new: true },
); );
return doc ? (doc.toJSON() as unknown as ChatMessage) : null; return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
} }
async getMessageById(messageId: string): Promise<ChatMessage | null> {
const doc = await ChatMessageModel.findById(messageId);
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
}
} }

View File

@@ -28,6 +28,14 @@ export class MongoEventRepository implements EventRepository {
return events.map((e) => e.toJSON() as unknown as CalendarEvent); return events.map((e) => e.toJSON() as unknown as CalendarEvent);
} }
async searchByTitle(userId: string, query: string): Promise<CalendarEvent[]> {
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<CalendarEvent> { async create(userId: string, data: CreateEventDTO): Promise<CalendarEvent> {
const event = await EventModel.create({ userId, ...data }); const event = await EventModel.create({ userId, ...data });
// NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field // NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field

View File

@@ -5,6 +5,7 @@ import {
CreateEventDTO, CreateEventDTO,
UpdateEventDTO, UpdateEventDTO,
ProposedEventChange, ProposedEventChange,
ConflictingEvent,
} from "@calchat/shared"; } from "@calchat/shared";
import { IdVirtual } from "./types"; import { IdVirtual } from "./types";
@@ -41,6 +42,15 @@ const UpdatesSchema = new Schema<UpdateEventDTO>(
{ _id: false }, { _id: false },
); );
const ConflictingEventSchema = new Schema<ConflictingEvent>(
{
title: { type: String, required: true },
startTime: { type: Date, required: true },
endTime: { type: Date, required: true },
},
{ _id: false },
);
const ProposedChangeSchema = new Schema<ProposedEventChange>( const ProposedChangeSchema = new Schema<ProposedEventChange>(
{ {
id: { type: String, required: true }, id: { type: String, required: true },
@@ -61,6 +71,7 @@ const ProposedChangeSchema = new Schema<ProposedEventChange>(
enum: ["single", "future", "all"], enum: ["single", "future", "all"],
}, },
occurrenceDate: { type: String }, occurrenceDate: { type: String },
conflictingEvents: { type: [ConflictingEventSchema] },
}, },
{ _id: false }, { _id: false },
); );

View File

@@ -9,8 +9,8 @@ import {
CreateEventDTO, CreateEventDTO,
UpdateEventDTO, UpdateEventDTO,
EventAction, EventAction,
CreateMessageDTO,
RecurringDeleteMode, RecurringDeleteMode,
ConflictingEvent,
} from "@calchat/shared"; } from "@calchat/shared";
import { ChatRepository, EventRepository, AIProvider } from "./interfaces"; import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
import { EventService } from "./EventService"; import { EventService } from "./EventService";
@@ -570,7 +570,6 @@ export class ChatService {
responseIndex++; responseIndex++;
} else { } else {
// Production mode: use real AI // Production mode: use real AI
const events = await this.eventRepo.findByUserId(userId);
const history = await this.chatRepo.getMessages(conversationId, { const history = await this.chatRepo.getMessages(conversationId, {
limit: 20, limit: 20,
}); });
@@ -578,8 +577,16 @@ export class ChatService {
response = await this.aiProvider.processMessage(data.content, { response = await this.aiProvider.processMessage(data.content, {
userId, userId,
conversationHistory: history, conversationHistory: history,
existingEvents: events,
currentDate: new Date(), 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, proposalId: string,
event: CreateEventDTO, event: CreateEventDTO,
): Promise<ChatMessage | null> { ): Promise<ChatMessage | null> {
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,
);
} }
} }

View File

@@ -1,14 +1,21 @@
import { import {
CalendarEvent,
ChatMessage, ChatMessage,
ProposedEventChange, ProposedEventChange,
ExpandedEvent,
CalendarEvent,
} from "@calchat/shared"; } from "@calchat/shared";
export interface AIContext { export interface AIContext {
userId: string; userId: string;
conversationHistory: ChatMessage[]; conversationHistory: ChatMessage[];
existingEvents: CalendarEvent[];
currentDate: Date; 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<ExpandedEvent[]>;
// Callback to search events by title
searchEvents: (query: string) => Promise<CalendarEvent[]>;
// Callback to fetch a single event by ID
fetchEventById: (eventId: string) => Promise<CalendarEvent | null>;
} }
export interface AIResponse { export interface AIResponse {

View File

@@ -5,11 +5,13 @@ import {
CreateEventDTO, CreateEventDTO,
GetMessagesOptions, GetMessagesOptions,
UpdateMessageDTO, UpdateMessageDTO,
ConflictingEvent,
} from "@calchat/shared"; } from "@calchat/shared";
export interface ChatRepository { export interface ChatRepository {
// Conversations // Conversations
getConversationsByUser(userId: string): Promise<Conversation[]>; getConversationsByUser(userId: string): Promise<Conversation[]>;
getConversationById(conversationId: string): Promise<Conversation | null>;
createConversation(userId: string): Promise<Conversation>; createConversation(userId: string): Promise<Conversation>;
// Messages (cursor-based pagination) // Messages (cursor-based pagination)
@@ -38,5 +40,8 @@ export interface ChatRepository {
messageId: string, messageId: string,
proposalId: string, proposalId: string,
event: CreateEventDTO, event: CreateEventDTO,
conflictingEvents?: ConflictingEvent[],
): Promise<ChatMessage | null>; ): Promise<ChatMessage | null>;
getMessageById(messageId: string): Promise<ChatMessage | null>;
} }

View File

@@ -8,6 +8,7 @@ export interface EventRepository {
startDate: Date, startDate: Date,
endDate: Date, endDate: Date,
): Promise<CalendarEvent[]>; ): Promise<CalendarEvent[]>;
searchByTitle(userId: string, query: string): Promise<CalendarEvent[]>;
create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>; create(userId: string, data: CreateEventDTO): Promise<CalendarEvent>;
update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>; update(id: string, data: UpdateEventDTO): Promise<CalendarEvent | null>;
delete(id: string): Promise<boolean>; delete(id: string): Promise<boolean>;

View File

@@ -10,6 +10,12 @@ export type EventAction = "create" | "update" | "delete";
export type RespondedAction = "confirm" | "reject"; export type RespondedAction = "confirm" | "reject";
export interface ConflictingEvent {
title: string;
startTime: Date;
endTime: Date;
}
export interface ProposedEventChange { export interface ProposedEventChange {
id: string; // Unique ID for each proposal id: string; // Unique ID for each proposal
action: EventAction; action: EventAction;
@@ -19,6 +25,7 @@ export interface ProposedEventChange {
respondedAction?: RespondedAction; // User's response to this specific proposal respondedAction?: RespondedAction; // User's response to this specific proposal
deleteMode?: RecurringDeleteMode; // For recurring event deletion deleteMode?: RecurringDeleteMode; // For recurring event deletion
occurrenceDate?: string; // ISO date string of specific occurrence for single/future delete occurrenceDate?: string; // ISO date string of specific occurrence for single/future delete
conflictingEvents?: ConflictingEvent[]; // Overlapping events for conflict warnings
} }
export interface ChatMessage { export interface ChatMessage {