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:
52
CLAUDE.md
52
CLAUDE.md
@@ -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.)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add assistant message with ALL tool calls at once
|
||||||
messages.push({
|
messages.push({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
tool_calls: [toolCall],
|
tool_calls: assistantMessage.tool_calls,
|
||||||
|
content: assistantMessage.content,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add tool result
|
// 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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."`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user