feat: support multiple event proposals in single AI response
- Change proposedChange to proposedChanges array in ChatMessage type - Add unique id and individual respondedAction to each ProposedEventChange - Implement arrow navigation UI for multiple proposals with "Event X von Y" counter - Add updateProposalResponse() method for per-proposal confirm/reject tracking - GPTAdapter now collects multiple tool call results into proposals array - Add RRULE documentation to system prompt (separate events for different times) - Fix RRULE parsing to strip RRULE: prefix if present - Add log summarization for large args (conversationHistory, existingEvents) - Keep proposedChanges logged in full for debugging AI issues
This commit is contained in:
27
CLAUDE.md
27
CLAUDE.md
@@ -205,15 +205,17 @@ src/
|
|||||||
- `User`: id, email, userName, passwordHash?, createdAt?, updatedAt?
|
- `User`: id, email, userName, passwordHash?, createdAt?, updatedAt?
|
||||||
- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule?
|
- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule?
|
||||||
- `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, proposedChange?, respondedAction?
|
- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChanges?
|
||||||
- `ProposedEventChange`: action ('create' | 'update' | 'delete'), eventId?, event?, updates?
|
- `ProposedEventChange`: id, action ('create' | 'update' | 'delete'), eventId?, event?, updates?, respondedAction?
|
||||||
|
- Each proposal has unique `id` (e.g., "proposal-0") for individual confirm/reject
|
||||||
|
- `respondedAction` tracks user response per proposal (not per message)
|
||||||
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
|
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
|
||||||
- `CreateUserDTO`: email, userName, password (for registration)
|
- `CreateUserDTO`: email, userName, password (for registration)
|
||||||
- `LoginDTO`: identifier (email OR userName), password
|
- `LoginDTO`: identifier (email OR userName), password
|
||||||
- `CreateEventDTO`: Used for creating events AND for AI-proposed events
|
- `CreateEventDTO`: Used for creating events AND for AI-proposed events
|
||||||
- `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number`
|
- `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number`
|
||||||
- `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list)
|
- `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list)
|
||||||
- `UpdateMessageDTO`: respondedAction? (for marking messages as confirmed/rejected)
|
- `UpdateMessageDTO`: proposalId?, respondedAction? (for marking individual proposals as confirmed/rejected)
|
||||||
- `RespondedAction`: 'confirm' | 'reject' (tracks user response to proposed events)
|
- `RespondedAction`: 'confirm' | 'reject' (tracks user response to proposed events)
|
||||||
- `Day`: "Monday" | "Tuesday" | ... | "Sunday"
|
- `Day`: "Monday" | "Tuesday" | ... | "Sunday"
|
||||||
- `Month`: "January" | "February" | ... | "December"
|
- `Month`: "January" | "February" | ... | "December"
|
||||||
@@ -275,6 +277,14 @@ export class GPTAdapter implements AIProvider { ... }
|
|||||||
|
|
||||||
The decorator uses a Proxy to intercept method calls lazily, preserves sync/async nature, and logs start/completion/failure with duration.
|
The decorator uses a Proxy to intercept method calls lazily, preserves sync/async nature, and logs start/completion/failure with duration.
|
||||||
|
|
||||||
|
**Log Summarization:**
|
||||||
|
The `@Logged` decorator automatically summarizes large arguments to keep logs readable:
|
||||||
|
- `conversationHistory` → `"[5 messages]"`
|
||||||
|
- `existingEvents` → `"[3 events]"`
|
||||||
|
- `proposedChanges` → logged in full (for debugging AI issues)
|
||||||
|
- Long strings (>100 chars) → truncated
|
||||||
|
- Arrays → `"[Array(n)]"`
|
||||||
|
|
||||||
**Client Logging:**
|
**Client Logging:**
|
||||||
- `react-native-logs` with namespaced loggers (apiLogger, storeLogger)
|
- `react-native-logs` with namespaced loggers (apiLogger, storeLogger)
|
||||||
- ApiClient logs all requests with method, endpoint, status, duration
|
- ApiClient logs all requests with method, endpoint, status, duration
|
||||||
@@ -339,10 +349,12 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
|||||||
- `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing
|
- `utils/recurrenceExpander`: expandRecurringEvents() using rrule library for RRULE parsing
|
||||||
- `ChatController`: getConversations(), getConversation() with cursor-based pagination support
|
- `ChatController`: getConversations(), getConversation() with cursor-based pagination support
|
||||||
- `ChatService`: getConversations(), getConversation(), processMessage() uses real AI or test responses (via USE_TEST_RESPONSES), confirmEvent()/rejectEvent() update respondedAction and persist response messages
|
- `ChatService`: getConversations(), getConversation(), processMessage() uses real AI or test responses (via USE_TEST_RESPONSES), confirmEvent()/rejectEvent() update respondedAction and persist response messages
|
||||||
- `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage)
|
- `MongoChatRepository`: Full CRUD implemented (getConversationsByUser, createConversation, getMessages with cursor pagination, createMessage, updateMessage, updateProposalResponse)
|
||||||
- `ChatRepository` interface: updateMessage() added for respondedAction tracking
|
- `ChatRepository` interface: updateMessage() and updateProposalResponse() for per-proposal respondedAction tracking
|
||||||
- `GPTAdapter`: Full implementation with OpenAI GPT (gpt-5-mini model), function calling for calendar operations
|
- `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, eventFormatter)
|
||||||
|
- `ai/utils/systemPrompt`: Includes RRULE documentation - AI knows to create separate events when times differ by day
|
||||||
|
- `utils/recurrenceExpander`: Handles RRULE parsing, strips `RRULE:` prefix if present (AI may include it)
|
||||||
- `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
|
||||||
- CORS configured to allow X-User-Id header
|
- CORS configured to allow X-User-Id header
|
||||||
@@ -373,6 +385,9 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
|||||||
- Supports events from adjacent months visible in grid
|
- Supports events from adjacent months visible in grid
|
||||||
- Uses `useFocusEffect` for automatic reload on tab focus
|
- Uses `useFocusEffect` for automatic reload on tab focus
|
||||||
- Chat screen fully functional with FlashList, message sending, and event confirm/reject
|
- Chat screen fully functional with FlashList, message sending, and event confirm/reject
|
||||||
|
- **Multiple event proposals**: AI can propose multiple events in one response
|
||||||
|
- Arrow navigation between proposals with "Event X von Y" counter
|
||||||
|
- Each proposal individually confirmable/rejectable
|
||||||
- Messages persisted to database via ChatService and loaded on mount
|
- Messages persisted to database via ChatService and loaded on mount
|
||||||
- Tracks conversationId for message continuity across sessions
|
- Tracks conversationId for message continuity across sessions
|
||||||
- ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper
|
- ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "../../stores";
|
} from "../../stores";
|
||||||
import { ProposedEventChange } from "@caldav/shared";
|
import { ProposedEventChange } from "@caldav/shared";
|
||||||
import { ProposedEventCard } from "../../components/ProposedEventCard";
|
import { ProposedEventCard } from "../../components/ProposedEventCard";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
// TODO: better shadows for everything
|
// TODO: better shadows for everything
|
||||||
// (maybe with extra library because of differences between android and ios)
|
// (maybe with extra library because of differences between android and ios)
|
||||||
@@ -30,10 +31,9 @@ type BubbleSide = "left" | "right";
|
|||||||
type ChatMessageProps = {
|
type ChatMessageProps = {
|
||||||
side: BubbleSide;
|
side: BubbleSide;
|
||||||
content: string;
|
content: string;
|
||||||
proposedChange?: ProposedEventChange;
|
proposedChanges?: ProposedEventChange[];
|
||||||
respondedAction?: "confirm" | "reject";
|
onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void;
|
||||||
onConfirm?: () => void;
|
onReject?: (proposalId: string) => void;
|
||||||
onReject?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChatInputProps = {
|
type ChatInputProps = {
|
||||||
@@ -88,10 +88,17 @@ const Chat = () => {
|
|||||||
action: "confirm" | "reject",
|
action: "confirm" | "reject",
|
||||||
messageId: string,
|
messageId: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
proposalId: string,
|
||||||
proposedChange?: ProposedEventChange,
|
proposedChange?: ProposedEventChange,
|
||||||
) => {
|
) => {
|
||||||
// Mark message as responded (optimistic update)
|
// Mark proposal as responded (optimistic update)
|
||||||
updateMessage(messageId, { respondedAction: action });
|
const message = messages.find((m) => m.id === messageId);
|
||||||
|
if (message?.proposedChanges) {
|
||||||
|
const updatedProposals = message.proposedChanges.map((p) =>
|
||||||
|
p.id === proposalId ? { ...p, respondedAction: action as "confirm" | "reject" } : p,
|
||||||
|
);
|
||||||
|
updateMessage(messageId, { proposedChanges: updatedProposals });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response =
|
const response =
|
||||||
@@ -99,12 +106,13 @@ const Chat = () => {
|
|||||||
? await ChatService.confirmEvent(
|
? await ChatService.confirmEvent(
|
||||||
conversationId,
|
conversationId,
|
||||||
messageId,
|
messageId,
|
||||||
|
proposalId,
|
||||||
proposedChange.action,
|
proposedChange.action,
|
||||||
proposedChange.event,
|
proposedChange.event,
|
||||||
proposedChange.eventId,
|
proposedChange.eventId,
|
||||||
proposedChange.updates,
|
proposedChange.updates,
|
||||||
)
|
)
|
||||||
: await ChatService.rejectEvent(conversationId, messageId);
|
: await ChatService.rejectEvent(conversationId, messageId, proposalId);
|
||||||
|
|
||||||
const botMessage: MessageData = {
|
const botMessage: MessageData = {
|
||||||
id: response.message.id,
|
id: response.message.id,
|
||||||
@@ -117,7 +125,12 @@ const Chat = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to ${action} event:`, error);
|
console.error(`Failed to ${action} event:`, error);
|
||||||
// Revert on error
|
// Revert on error
|
||||||
updateMessage(messageId, { respondedAction: undefined });
|
if (message?.proposedChanges) {
|
||||||
|
const revertedProposals = message.proposedChanges.map((p) =>
|
||||||
|
p.id === proposalId ? { ...p, respondedAction: undefined } : p,
|
||||||
|
);
|
||||||
|
updateMessage(messageId, { proposedChanges: revertedProposals });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,7 +162,7 @@ const Chat = () => {
|
|||||||
id: response.message.id,
|
id: response.message.id,
|
||||||
side: "left",
|
side: "left",
|
||||||
content: response.message.content,
|
content: response.message.content,
|
||||||
proposedChange: response.message.proposedChange,
|
proposedChanges: response.message.proposedChanges,
|
||||||
conversationId: response.conversationId,
|
conversationId: response.conversationId,
|
||||||
};
|
};
|
||||||
addMessage(botMessage);
|
addMessage(botMessage);
|
||||||
@@ -173,18 +186,18 @@ const Chat = () => {
|
|||||||
<ChatMessage
|
<ChatMessage
|
||||||
side={item.side}
|
side={item.side}
|
||||||
content={item.content}
|
content={item.content}
|
||||||
proposedChange={item.proposedChange}
|
proposedChanges={item.proposedChanges}
|
||||||
respondedAction={item.respondedAction}
|
onConfirm={(proposalId, proposal) =>
|
||||||
onConfirm={() =>
|
|
||||||
handleEventResponse(
|
handleEventResponse(
|
||||||
"confirm",
|
"confirm",
|
||||||
item.id,
|
item.id,
|
||||||
item.conversationId!,
|
item.conversationId!,
|
||||||
item.proposedChange,
|
proposalId,
|
||||||
|
proposal,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onReject={() =>
|
onReject={(proposalId) =>
|
||||||
handleEventResponse("reject", item.id, item.conversationId!)
|
handleEventResponse("reject", item.id, item.conversationId!, proposalId)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -271,11 +284,12 @@ const ChatInput = ({ onSend }: ChatInputProps) => {
|
|||||||
const ChatMessage = ({
|
const ChatMessage = ({
|
||||||
side,
|
side,
|
||||||
content,
|
content,
|
||||||
proposedChange,
|
proposedChanges,
|
||||||
respondedAction,
|
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onReject,
|
onReject,
|
||||||
}: ChatMessageProps) => {
|
}: ChatMessageProps) => {
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
|
||||||
const borderColor =
|
const borderColor =
|
||||||
side === "left" ? currentTheme.chatBot : currentTheme.primeFg;
|
side === "left" ? currentTheme.chatBot : currentTheme.primeFg;
|
||||||
const selfSide =
|
const selfSide =
|
||||||
@@ -283,24 +297,87 @@ const ChatMessage = ({
|
|||||||
? "self-start ml-2 rounded-bl-sm"
|
? "self-start ml-2 rounded-bl-sm"
|
||||||
: "self-end mr-2 rounded-br-sm";
|
: "self-end mr-2 rounded-br-sm";
|
||||||
|
|
||||||
|
const hasProposals = proposedChanges && proposedChanges.length > 0;
|
||||||
|
const hasMultiple = proposedChanges && proposedChanges.length > 1;
|
||||||
|
const currentProposal = proposedChanges?.[currentIndex];
|
||||||
|
|
||||||
|
const goToPrev = () => setCurrentIndex((i) => Math.max(0, i - 1));
|
||||||
|
const goToNext = () =>
|
||||||
|
setCurrentIndex((i) =>
|
||||||
|
Math.min((proposedChanges?.length || 1) - 1, i + 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const canGoPrev = currentIndex > 0;
|
||||||
|
const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`bg-white border-2 border-solid rounded-xl my-2 ${selfSide}`}
|
className={`bg-white border-2 border-solid rounded-xl my-2 ${selfSide}`}
|
||||||
style={{
|
style={{
|
||||||
borderColor: borderColor,
|
borderColor: borderColor,
|
||||||
maxWidth: "80%",
|
maxWidth: "80%",
|
||||||
|
minWidth: hasProposals ? "75%" : undefined,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="p-2">{content}</Text>
|
<Text className="p-2">{content}</Text>
|
||||||
|
|
||||||
{proposedChange && onConfirm && onReject && (
|
{hasProposals && currentProposal && onConfirm && onReject && (
|
||||||
<ProposedEventCard
|
<View>
|
||||||
proposedChange={proposedChange}
|
{/* Event card with optional navigation arrows */}
|
||||||
respondedAction={respondedAction}
|
<View className="flex-row items-center">
|
||||||
onConfirm={onConfirm}
|
{/* Left arrow */}
|
||||||
onReject={onReject}
|
{hasMultiple && (
|
||||||
|
<Pressable
|
||||||
|
onPress={goToPrev}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
className="p-1"
|
||||||
|
style={{ opacity: canGoPrev ? 1 : 0.3 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-back"
|
||||||
|
size={24}
|
||||||
|
color={currentTheme.primeFg}
|
||||||
/>
|
/>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event Card */}
|
||||||
|
<View className="flex-1">
|
||||||
|
<ProposedEventCard
|
||||||
|
proposedChange={currentProposal}
|
||||||
|
onConfirm={() => onConfirm(currentProposal.id, currentProposal)}
|
||||||
|
onReject={() => onReject(currentProposal.id)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right arrow */}
|
||||||
|
{hasMultiple && (
|
||||||
|
<Pressable
|
||||||
|
onPress={goToNext}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
className="p-1"
|
||||||
|
style={{ opacity: canGoNext ? 1 : 0.3 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-forward"
|
||||||
|
size={24}
|
||||||
|
color={currentTheme.primeFg}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Event counter */}
|
||||||
|
{hasMultiple && (
|
||||||
|
<Text
|
||||||
|
className="text-center text-sm pb-2"
|
||||||
|
style={{ color: currentTheme.textSecondary || "#666" }}
|
||||||
|
>
|
||||||
|
Event {currentIndex + 1} von {proposedChanges.length}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const Header = (props: HeaderProps) => {
|
|||||||
{props.children}
|
{props.children}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleLogout}
|
onPress={handleLogout}
|
||||||
className="absolute right-1 top-0 p-2"
|
className="absolute left-1 bottom-0 p-2"
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<Ionicons name="log-out-outline" size={24} color={currentTheme.primeFg} />
|
<Ionicons name="log-out-outline" size={24} color={currentTheme.primeFg} />
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { EventCardBase } from "./EventCardBase";
|
|||||||
|
|
||||||
type ProposedEventCardProps = {
|
type ProposedEventCardProps = {
|
||||||
proposedChange: ProposedEventChange;
|
proposedChange: ProposedEventChange;
|
||||||
respondedAction?: "confirm" | "reject";
|
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
};
|
};
|
||||||
@@ -59,12 +58,12 @@ const ConfirmRejectButtons = ({
|
|||||||
|
|
||||||
export const ProposedEventCard = ({
|
export const ProposedEventCard = ({
|
||||||
proposedChange,
|
proposedChange,
|
||||||
respondedAction,
|
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onReject,
|
onReject,
|
||||||
}: ProposedEventCardProps) => {
|
}: ProposedEventCardProps) => {
|
||||||
const event = proposedChange.event;
|
const event = proposedChange.event;
|
||||||
const isDisabled = !!respondedAction;
|
// respondedAction is now part of the proposedChange
|
||||||
|
const isDisabled = !!proposedChange.respondedAction;
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return null;
|
return null;
|
||||||
@@ -82,7 +81,7 @@ export const ProposedEventCard = ({
|
|||||||
>
|
>
|
||||||
<ConfirmRejectButtons
|
<ConfirmRejectButtons
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
respondedAction={respondedAction}
|
respondedAction={proposedChange.respondedAction}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
onReject={onReject}
|
onReject={onReject}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,12 +11,17 @@ import {
|
|||||||
import { ApiClient } from "./ApiClient";
|
import { ApiClient } from "./ApiClient";
|
||||||
|
|
||||||
interface ConfirmEventRequest {
|
interface ConfirmEventRequest {
|
||||||
|
proposalId: string;
|
||||||
action: EventAction;
|
action: EventAction;
|
||||||
event?: CreateEventDTO;
|
event?: CreateEventDTO;
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
updates?: UpdateEventDTO;
|
updates?: UpdateEventDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RejectEventRequest {
|
||||||
|
proposalId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const ChatService = {
|
export const ChatService = {
|
||||||
sendMessage: async (data: SendMessageDTO): Promise<ChatResponse> => {
|
sendMessage: async (data: SendMessageDTO): Promise<ChatResponse> => {
|
||||||
return ApiClient.post<ChatResponse>("/chat/message", data);
|
return ApiClient.post<ChatResponse>("/chat/message", data);
|
||||||
@@ -25,12 +30,19 @@ export const ChatService = {
|
|||||||
confirmEvent: async (
|
confirmEvent: async (
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
|
proposalId: string,
|
||||||
action: EventAction,
|
action: EventAction,
|
||||||
event?: CreateEventDTO,
|
event?: CreateEventDTO,
|
||||||
eventId?: string,
|
eventId?: string,
|
||||||
updates?: UpdateEventDTO,
|
updates?: UpdateEventDTO,
|
||||||
): Promise<ChatResponse> => {
|
): Promise<ChatResponse> => {
|
||||||
const body: ConfirmEventRequest = { action, event, eventId, updates };
|
const body: ConfirmEventRequest = {
|
||||||
|
proposalId,
|
||||||
|
action,
|
||||||
|
event,
|
||||||
|
eventId,
|
||||||
|
updates,
|
||||||
|
};
|
||||||
return ApiClient.post<ChatResponse>(
|
return ApiClient.post<ChatResponse>(
|
||||||
`/chat/confirm/${conversationId}/${messageId}`,
|
`/chat/confirm/${conversationId}/${messageId}`,
|
||||||
body,
|
body,
|
||||||
@@ -40,9 +52,12 @@ export const ChatService = {
|
|||||||
rejectEvent: async (
|
rejectEvent: async (
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
|
proposalId: string,
|
||||||
): Promise<ChatResponse> => {
|
): Promise<ChatResponse> => {
|
||||||
|
const body: RejectEventRequest = { proposalId };
|
||||||
return ApiClient.post<ChatResponse>(
|
return ApiClient.post<ChatResponse>(
|
||||||
`/chat/reject/${conversationId}/${messageId}`,
|
`/chat/reject/${conversationId}/${messageId}`,
|
||||||
|
body,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ export type MessageData = {
|
|||||||
id: string;
|
id: string;
|
||||||
side: BubbleSide;
|
side: BubbleSide;
|
||||||
content: string;
|
content: string;
|
||||||
proposedChange?: ProposedEventChange;
|
proposedChanges?: ProposedEventChange[];
|
||||||
respondedAction?: "confirm" | "reject";
|
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,8 +45,7 @@ export function chatMessageToMessageData(msg: ChatMessage): MessageData {
|
|||||||
id: msg.id,
|
id: msg.id,
|
||||||
side: msg.sender === "assistant" ? "left" : "right",
|
side: msg.sender === "assistant" ? "left" : "right",
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
proposedChange: msg.proposedChange,
|
proposedChanges: msg.proposedChanges,
|
||||||
respondedAction: msg.respondedAction,
|
|
||||||
conversationId: msg.conversationId,
|
conversationId: msg.conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export class GPTAdapter implements AIProvider {
|
|||||||
// Add current user message
|
// Add current user message
|
||||||
messages.push({ role: "user", content: message });
|
messages.push({ role: "user", content: message });
|
||||||
|
|
||||||
let proposedChange: ProposedEventChange | undefined;
|
const proposedChanges: ProposedEventChange[] = [];
|
||||||
|
let proposalIndex = 0;
|
||||||
|
|
||||||
// Tool calling loop
|
// Tool calling loop
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -81,7 +82,8 @@ export class GPTAdapter implements AIProvider {
|
|||||||
return {
|
return {
|
||||||
content:
|
content:
|
||||||
assistantMessage.content || "Ich konnte keine Antwort generieren.",
|
assistantMessage.content || "Ich konnte keine Antwort generieren.",
|
||||||
proposedChange,
|
proposedChanges:
|
||||||
|
proposedChanges.length > 0 ? proposedChanges : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,9 +97,12 @@ export class GPTAdapter implements AIProvider {
|
|||||||
|
|
||||||
const result = executeToolCall(name, args, context);
|
const result = executeToolCall(name, args, context);
|
||||||
|
|
||||||
// If the tool returned a proposedChange, capture it
|
// If the tool returned a proposedChange, add it to the array with unique ID
|
||||||
if (result.proposedChange) {
|
if (result.proposedChange) {
|
||||||
proposedChange = result.proposedChange;
|
proposedChanges.push({
|
||||||
|
id: `proposal-${proposalIndex++}`,
|
||||||
|
...result.proposedChange,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add assistant message with tool call
|
// Add assistant message with tool call
|
||||||
|
|||||||
@@ -28,10 +28,33 @@ Wichtige Regeln:
|
|||||||
- Wenn der Benutzer einen Termin erstellen will, nutze proposeCreateEvent
|
- Wenn der Benutzer einen Termin erstellen will, nutze proposeCreateEvent
|
||||||
- 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 NUR EINEN Event-Vorschlag pro Antwort machen
|
- 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
|
- Wenn der Benutzer nach seinen Terminen fragt, nutze die unten stehende Liste
|
||||||
- Nutze searchEvents um nach Terminen zu suchen, wenn du die genaue ID brauchst
|
- Nutze searchEvents um nach Terminen zu suchen, wenn du die genaue ID brauchst
|
||||||
|
|
||||||
|
WICHTIG - Tool-Verwendung:
|
||||||
|
- Du MUSST die proposeCreateEvent/proposeUpdateEvent/proposeDeleteEvent Tools verwenden, um Termine vorzuschlagen!
|
||||||
|
- Sage NIEMALS einfach nur "ich habe einen Termin erstellt" ohne das Tool zu verwenden
|
||||||
|
- Die Tools erzeugen Karten, die dem Benutzer angezeigt werden - ohne Tool-Aufruf sieht er nichts
|
||||||
|
|
||||||
|
WICHTIG - Wiederkehrende Termine (RRULE):
|
||||||
|
- Ein wiederkehrendes Event hat EINE FESTE Start- und Endzeit
|
||||||
|
- RRULE bestimmt NUR an welchen Tagen das Event wiederholt wird, NICHT unterschiedliche Uhrzeiten pro Tag!
|
||||||
|
- Wenn der Benutzer UNTERSCHIEDLICHE ZEITEN an verschiedenen Tagen will, MUSST du SEPARATE Events erstellen
|
||||||
|
- Beispiel: "Arbeit Mo+Do 9-17:30, Fr 9-13" → ZWEI Events:
|
||||||
|
1. "Arbeit" Mo+Do 9:00-17:30 (RRULE mit BYDAY=MO,TH)
|
||||||
|
2. "Arbeit" Fr 9:00-13:00 (RRULE mit BYDAY=FR)
|
||||||
|
- Nutze NIEMALS BYHOUR/BYMINUTE in RRULE - diese überschreiben die Startzeit nicht wie erwartet!
|
||||||
|
- Gültige RRULE-Optionen: FREQ (DAILY/WEEKLY/MONTHLY/YEARLY), BYDAY (MO,TU,WE,TH,FR,SA,SU), INTERVAL, COUNT, UNTIL
|
||||||
|
|
||||||
|
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
|
||||||
|
- Gute Beispiele: "Alles klar!" oder "Hier sind deine Termine:"
|
||||||
|
- Schlechte Beispiele: Lange Listen mit allen Terminen und ihren Details im Text
|
||||||
|
- Bei Rückfragen oder wenn keine Termine erstellt werden, kannst du ausführlicher antworten
|
||||||
|
|
||||||
Existierende Termine des Benutzers:
|
Existierende Termine des Benutzers:
|
||||||
${eventsText}`;
|
${eventsText}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ import {
|
|||||||
import { AIContext } from "../../services/interfaces";
|
import { AIContext } from "../../services/interfaces";
|
||||||
import { formatDate, formatTime, formatDateTime } from "./eventFormatter";
|
import { formatDate, formatTime, formatDateTime } from "./eventFormatter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proposed change without ID - ID is added by GPTAdapter when collecting proposals
|
||||||
|
*/
|
||||||
|
type ToolProposedChange = Omit<ProposedEventChange, "id" | "respondedAction">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of executing a tool call.
|
* Result of executing a tool call.
|
||||||
*/
|
*/
|
||||||
export interface ToolResult {
|
export interface ToolResult {
|
||||||
content: string;
|
content: string;
|
||||||
proposedChange?: ProposedEventChange;
|
proposedChange?: ToolProposedChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export class ChatController {
|
|||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const { conversationId, messageId } = req.params;
|
const { conversationId, messageId } = req.params;
|
||||||
const { action, event, eventId, updates } = req.body as {
|
const { proposalId, action, event, eventId, updates } = req.body as {
|
||||||
|
proposalId: string;
|
||||||
action: EventAction;
|
action: EventAction;
|
||||||
event?: CreateEventDTO;
|
event?: CreateEventDTO;
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
@@ -41,6 +42,7 @@ export class ChatController {
|
|||||||
userId,
|
userId,
|
||||||
conversationId,
|
conversationId,
|
||||||
messageId,
|
messageId,
|
||||||
|
proposalId,
|
||||||
action,
|
action,
|
||||||
event,
|
event,
|
||||||
eventId,
|
eventId,
|
||||||
@@ -57,10 +59,12 @@ export class ChatController {
|
|||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const { conversationId, messageId } = req.params;
|
const { conversationId, messageId } = req.params;
|
||||||
|
const { proposalId } = req.body as { proposalId: string };
|
||||||
const response = await this.chatService.rejectEvent(
|
const response = await this.chatService.rejectEvent(
|
||||||
userId,
|
userId,
|
||||||
conversationId,
|
conversationId,
|
||||||
messageId,
|
messageId,
|
||||||
|
proposalId,
|
||||||
);
|
);
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,57 @@
|
|||||||
import { createLogger } from "./logger";
|
import { createLogger } from "./logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize args for logging to avoid huge log entries.
|
||||||
|
* - Arrays: show length only
|
||||||
|
* - Long strings: truncate
|
||||||
|
* - Objects with conversationHistory/existingEvents: summarize
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function summarizeArgs(args: any[]): any[] {
|
||||||
|
return args.map((arg) => summarizeValue(arg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function summarizeValue(value: any, depth = 0): any {
|
||||||
|
if (depth > 2) return "[...]";
|
||||||
|
|
||||||
|
if (value === null || value === undefined) return value;
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `[Array(${value.length})]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string" && value.length > 100) {
|
||||||
|
return value.substring(0, 100) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object") {
|
||||||
|
// Summarize known large fields
|
||||||
|
const summarized: Record<string, unknown> = {};
|
||||||
|
for (const [key, val] of Object.entries(value)) {
|
||||||
|
if (key === "conversationHistory" && Array.isArray(val)) {
|
||||||
|
summarized[key] = `[${val.length} messages]`;
|
||||||
|
} else if (key === "existingEvents" && Array.isArray(val)) {
|
||||||
|
summarized[key] = `[${val.length} events]`;
|
||||||
|
} else if (key === "proposedChanges" && Array.isArray(val)) {
|
||||||
|
// Log full proposedChanges for debugging AI issues
|
||||||
|
summarized[key] = val.map((p) => summarizeValue(p, depth + 1));
|
||||||
|
} else if (Array.isArray(val)) {
|
||||||
|
summarized[key] = `[Array(${val.length})]`;
|
||||||
|
} else if (typeof val === "object" && val !== null) {
|
||||||
|
summarized[key] = summarizeValue(val, depth + 1);
|
||||||
|
} else if (typeof val === "string" && val.length > 100) {
|
||||||
|
summarized[key] = val.substring(0, 100) + "...";
|
||||||
|
} else {
|
||||||
|
summarized[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summarized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function Logged(name: string) {
|
export function Logged(name: string) {
|
||||||
const log = createLogger(name);
|
const log = createLogger(name);
|
||||||
|
|
||||||
@@ -27,8 +79,8 @@ export function Logged(name: string) {
|
|||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const method = String(propKey);
|
const method = String(propKey);
|
||||||
|
|
||||||
// Pino's redact handles sanitization - just pass args directly
|
// Summarize args to avoid huge log entries
|
||||||
log.debug({ method, args: methodArgs }, `${method} started`);
|
log.debug({ method, args: summarizeArgs(methodArgs) }, `${method} started`);
|
||||||
|
|
||||||
const logCompletion = (err?: unknown) => {
|
const logCompletion = (err?: unknown) => {
|
||||||
const duration = Math.round(performance.now() - start);
|
const duration = Math.round(performance.now() - start);
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export class MongoChatRepository implements ChatRepository {
|
|||||||
conversationId: conversationId,
|
conversationId: conversationId,
|
||||||
sender: message.sender,
|
sender: message.sender,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
proposedChange: message.proposedChange,
|
proposedChanges: message.proposedChanges,
|
||||||
});
|
});
|
||||||
return repoMessage.toJSON() as unknown as ChatMessage;
|
return repoMessage.toJSON() as unknown as ChatMessage;
|
||||||
}
|
}
|
||||||
@@ -69,4 +69,17 @@ export class MongoChatRepository implements ChatRepository {
|
|||||||
);
|
);
|
||||||
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
|
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateProposalResponse(
|
||||||
|
messageId: string,
|
||||||
|
proposalId: string,
|
||||||
|
respondedAction: "confirm" | "reject",
|
||||||
|
): Promise<ChatMessage | null> {
|
||||||
|
const doc = await ChatMessageModel.findOneAndUpdate(
|
||||||
|
{ _id: messageId, "proposedChanges.id": proposalId },
|
||||||
|
{ $set: { "proposedChanges.$.respondedAction": respondedAction } },
|
||||||
|
{ new: true },
|
||||||
|
);
|
||||||
|
return doc ? (doc.toJSON() as unknown as ChatMessage) : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const UpdatesSchema = new Schema<UpdateEventDTO>(
|
|||||||
|
|
||||||
const ProposedChangeSchema = new Schema<ProposedEventChange>(
|
const ProposedChangeSchema = new Schema<ProposedEventChange>(
|
||||||
{
|
{
|
||||||
|
id: { type: String, required: true },
|
||||||
action: {
|
action: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ["create", "update", "delete"],
|
enum: ["create", "update", "delete"],
|
||||||
@@ -52,6 +53,10 @@ const ProposedChangeSchema = new Schema<ProposedEventChange>(
|
|||||||
eventId: { type: String },
|
eventId: { type: String },
|
||||||
event: { type: EventSchema },
|
event: { type: EventSchema },
|
||||||
updates: { type: UpdatesSchema },
|
updates: { type: UpdatesSchema },
|
||||||
|
respondedAction: {
|
||||||
|
type: String,
|
||||||
|
enum: ["confirm", "reject"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ _id: false },
|
{ _id: false },
|
||||||
);
|
);
|
||||||
@@ -77,12 +82,9 @@ const ChatMessageSchema = new Schema<
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
proposedChange: {
|
proposedChanges: {
|
||||||
type: ProposedChangeSchema,
|
type: [ProposedChangeSchema],
|
||||||
},
|
default: undefined,
|
||||||
respondedAction: {
|
|
||||||
type: String,
|
|
||||||
enum: ["confirm", "reject"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
|
import { ChatRepository, EventRepository, AIProvider } from "./interfaces";
|
||||||
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
|
import { getWeeksOverview, getMonthOverview } from "../utils/eventFormatters";
|
||||||
|
|
||||||
type TestResponse = { content: string; proposedChange?: ProposedEventChange };
|
type TestResponse = { content: string; proposedChanges?: ProposedEventChange[] };
|
||||||
|
|
||||||
// Test response index (cycles through responses)
|
// Test response index (cycles through responses)
|
||||||
let responseIndex = 0;
|
let responseIndex = 0;
|
||||||
@@ -22,7 +22,134 @@ let responseIndex = 0;
|
|||||||
// Static test responses (event proposals)
|
// Static test responses (event proposals)
|
||||||
const staticResponses: TestResponse[] = [
|
const staticResponses: TestResponse[] = [
|
||||||
// {{{
|
// {{{
|
||||||
// Response 0: Help response (text only)
|
// === MULTI-EVENT TEST RESPONSES ===
|
||||||
|
// Response 0: 3 Meetings an verschiedenen Tagen
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"Alles klar! Ich erstelle dir 3 Team-Meetings für diese Woche:",
|
||||||
|
proposedChanges: [
|
||||||
|
{
|
||||||
|
id: "multi-1-a",
|
||||||
|
action: "create",
|
||||||
|
event: {
|
||||||
|
title: "Team-Meeting Montag",
|
||||||
|
startTime: getDay("Monday", 1, 10, 0),
|
||||||
|
endTime: getDay("Monday", 1, 11, 0),
|
||||||
|
description: "Wöchentliches Standup",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "multi-1-b",
|
||||||
|
action: "create",
|
||||||
|
event: {
|
||||||
|
title: "Team-Meeting Mittwoch",
|
||||||
|
startTime: getDay("Wednesday", 1, 10, 0),
|
||||||
|
endTime: getDay("Wednesday", 1, 11, 0),
|
||||||
|
description: "Sprint Planning",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "multi-1-c",
|
||||||
|
action: "create",
|
||||||
|
event: {
|
||||||
|
title: "Team-Meeting Freitag",
|
||||||
|
startTime: getDay("Friday", 1, 10, 0),
|
||||||
|
endTime: getDay("Friday", 1, 11, 0),
|
||||||
|
description: "Retrospektive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Response 1: 5 Termine für einen Projekttag
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"Ich habe deinen kompletten Projekttag am Dienstag geplant:",
|
||||||
|
proposedChanges: [
|
||||||
|
{
|
||||||
|
id: "multi-2-a",
|
||||||
|
action: "create",
|
||||||
|
event: {
|
||||||
|
title: "Kickoff-Meeting",
|
||||||
|
startTime: getDay("Tuesday", 1, 9, 0),
|
||||||
|
endTime: getDay("Tuesday", 1, 10, 0),
|
||||||
|
description: "Projektstart mit dem Team",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "multi-2-b",
|
||||||
|
action: "create",
|
||||||
|
event: {
|
||||||
|
title: "Design Review",
|
||||||
|
startTime: getDay("Tuesday", 1, 10, 30),
|
||||||
|
endTime: getDay("Tuesday", 1, 11, 30),
|
||||||
|
description: "UI/UX Besprechung",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "multi-2-c",
|
||||||
|
action: "create",
|
||||||
|
event: {
|
||||||
|
title: "Mittagspause",
|
||||||
|
startTime: getDay("Tuesday", 1, 12, 0),
|
||||||
|
endTime: getDay("Tuesday", 1, 13, 0),
|
||||||
|
description: "Team-Lunch",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "multi-2-d",
|
||||||
|
action: "create",
|
||||||
|
event: {
|
||||||
|
title: "Tech Review",
|
||||||
|
startTime: getDay("Tuesday", 1, 14, 0),
|
||||||
|
endTime: getDay("Tuesday", 1, 15, 30),
|
||||||
|
description: "Architektur-Diskussion",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "multi-2-e",
|
||||||
|
action: "create",
|
||||||
|
event: {
|
||||||
|
title: "Wrap-up",
|
||||||
|
startTime: getDay("Tuesday", 1, 16, 0),
|
||||||
|
endTime: getDay("Tuesday", 1, 16, 30),
|
||||||
|
description: "Zusammenfassung und nächste Schritte",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Response 2: 2 wiederkehrende Termine
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
"Ich erstelle dir zwei wiederkehrende Fitness-Termine:",
|
||||||
|
proposedChanges: [
|
||||||
|
{
|
||||||
|
id: "multi-3-a",
|
||||||
|
action: "create",
|
||||||
|
event: {
|
||||||
|
title: "Yoga",
|
||||||
|
startTime: getDay("Monday", 1, 7, 0),
|
||||||
|
endTime: getDay("Monday", 1, 8, 0),
|
||||||
|
description: "Morgen-Yoga",
|
||||||
|
isRecurring: true,
|
||||||
|
recurrenceRule: "FREQ=WEEKLY;BYDAY=MO,WE,FR",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "multi-3-b",
|
||||||
|
action: "create",
|
||||||
|
event: {
|
||||||
|
title: "Laufen",
|
||||||
|
startTime: getDay("Tuesday", 1, 18, 0),
|
||||||
|
endTime: getDay("Tuesday", 1, 19, 0),
|
||||||
|
description: "Abendlauf im Park",
|
||||||
|
isRecurring: true,
|
||||||
|
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU,TH",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// === ORIGINAL RESPONSES ===
|
||||||
|
// Response 3: Help response (text only)
|
||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"Ich bin dein Kalender-Assistent! Du kannst mir einfach sagen, welche Termine du erstellen, ändern oder löschen möchtest. Zum Beispiel:\n\n" +
|
"Ich bin dein Kalender-Assistent! Du kannst mir einfach sagen, welche Termine du erstellen, ändern oder löschen möchtest. Zum Beispiel:\n\n" +
|
||||||
@@ -35,7 +162,9 @@ const staticResponses: TestResponse[] = [
|
|||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:",
|
"Alles klar! Ich erstelle dir einen Termin für das Meeting mit Jens am nächsten Freitag um 14:00 Uhr:",
|
||||||
proposedChange: {
|
proposedChanges: [
|
||||||
|
{
|
||||||
|
id: "test-1",
|
||||||
action: "create",
|
action: "create",
|
||||||
event: {
|
event: {
|
||||||
title: "Meeting mit Jens",
|
title: "Meeting mit Jens",
|
||||||
@@ -44,12 +173,15 @@ const staticResponses: TestResponse[] = [
|
|||||||
description: "Arbeitstreffen",
|
description: "Arbeitstreffen",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// Response 2: Recurring event - every Saturday 10:00
|
// Response 2: Recurring event - every Saturday 10:00
|
||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:",
|
"Verstanden! Ich erstelle einen wiederkehrenden Termin: Jeden Samstag um 10:00 Uhr Badezimmer putzen:",
|
||||||
proposedChange: {
|
proposedChanges: [
|
||||||
|
{
|
||||||
|
id: "test-2",
|
||||||
action: "create",
|
action: "create",
|
||||||
event: {
|
event: {
|
||||||
title: "Badezimmer putzen",
|
title: "Badezimmer putzen",
|
||||||
@@ -59,6 +191,7 @@ const staticResponses: TestResponse[] = [
|
|||||||
recurrenceRule: "FREQ=WEEKLY;BYDAY=SA",
|
recurrenceRule: "FREQ=WEEKLY;BYDAY=SA",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// Response 3: 2-week overview (DYNAMIC - placeholder)
|
// Response 3: 2-week overview (DYNAMIC - placeholder)
|
||||||
{ content: "" },
|
{ content: "" },
|
||||||
@@ -68,7 +201,9 @@ const staticResponses: TestResponse[] = [
|
|||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!",
|
"Ich habe dir einen Arzttermin eingetragen. Denk daran, deine Versichertenkarte mitzunehmen!",
|
||||||
proposedChange: {
|
proposedChanges: [
|
||||||
|
{
|
||||||
|
id: "test-5",
|
||||||
action: "create",
|
action: "create",
|
||||||
event: {
|
event: {
|
||||||
title: "Arzttermin Dr. Müller",
|
title: "Arzttermin Dr. Müller",
|
||||||
@@ -77,12 +212,15 @@ const staticResponses: TestResponse[] = [
|
|||||||
description: "Routineuntersuchung - Versichertenkarte nicht vergessen",
|
description: "Routineuntersuchung - Versichertenkarte nicht vergessen",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// Response 6: Birthday - yearly recurring
|
// Response 6: Birthday - yearly recurring
|
||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"Geburtstage vergisst man leicht - aber nicht mit mir! Ich habe Mamas Geburtstag eingetragen:",
|
"Geburtstage vergisst man leicht - aber nicht mit mir! Ich habe Mamas Geburtstag eingetragen:",
|
||||||
proposedChange: {
|
proposedChanges: [
|
||||||
|
{
|
||||||
|
id: "test-6",
|
||||||
action: "create",
|
action: "create",
|
||||||
event: {
|
event: {
|
||||||
title: "Mamas Geburtstag",
|
title: "Mamas Geburtstag",
|
||||||
@@ -92,12 +230,15 @@ const staticResponses: TestResponse[] = [
|
|||||||
recurrenceRule: "FREQ=YEARLY",
|
recurrenceRule: "FREQ=YEARLY",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// Response 7: Gym - recurring for 2 months (8 weeks)
|
// Response 7: Gym - recurring for 2 months (8 weeks)
|
||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"Perfekt! Ich habe dein Probetraining eingetragen - jeden Dienstag für die nächsten 2 Monate:",
|
"Perfekt! Ich habe dein Probetraining eingetragen - jeden Dienstag für die nächsten 2 Monate:",
|
||||||
proposedChange: {
|
proposedChanges: [
|
||||||
|
{
|
||||||
|
id: "test-7",
|
||||||
action: "create",
|
action: "create",
|
||||||
event: {
|
event: {
|
||||||
title: "Fitnessstudio Probetraining",
|
title: "Fitnessstudio Probetraining",
|
||||||
@@ -107,6 +248,7 @@ const staticResponses: TestResponse[] = [
|
|||||||
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8",
|
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU;COUNT=8",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// Response 8: 1-week overview (DYNAMIC - placeholder)
|
// Response 8: 1-week overview (DYNAMIC - placeholder)
|
||||||
{ content: "" },
|
{ content: "" },
|
||||||
@@ -114,7 +256,9 @@ const staticResponses: TestResponse[] = [
|
|||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:",
|
"Alles klar! Ich habe das Telefonat mit deiner Mutter eingetragen:",
|
||||||
proposedChange: {
|
proposedChanges: [
|
||||||
|
{
|
||||||
|
id: "test-9",
|
||||||
action: "create",
|
action: "create",
|
||||||
event: {
|
event: {
|
||||||
title: "Telefonat mit Mama",
|
title: "Telefonat mit Mama",
|
||||||
@@ -122,13 +266,16 @@ const staticResponses: TestResponse[] = [
|
|||||||
endTime: getDay("Wednesday", 1, 11, 30),
|
endTime: getDay("Wednesday", 1, 11, 30),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// Response 10: Update "Telefonat mit Mama" +3 days (DYNAMIC - placeholder)
|
// Response 10: Update "Telefonat mit Mama" +3 days (DYNAMIC - placeholder)
|
||||||
{ content: "" },
|
{ content: "" },
|
||||||
// Response 11: Birthday party - evening event
|
// Response 11: Birthday party - evening event
|
||||||
{
|
{
|
||||||
content: "Super! Die Geburtstagsfeier ist eingetragen. Viel Spaß!",
|
content: "Super! Die Geburtstagsfeier ist eingetragen. Viel Spaß!",
|
||||||
proposedChange: {
|
proposedChanges: [
|
||||||
|
{
|
||||||
|
id: "test-11",
|
||||||
action: "create",
|
action: "create",
|
||||||
event: {
|
event: {
|
||||||
title: "Geburtstagsfeier Lisa",
|
title: "Geburtstagsfeier Lisa",
|
||||||
@@ -137,12 +284,15 @@ const staticResponses: TestResponse[] = [
|
|||||||
description: "Geschenk: Buch über Fotografie",
|
description: "Geschenk: Buch über Fotografie",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// Response 12: Language course - limited to 8 weeks (Thu + Sat)
|
// Response 12: Language course - limited to 8 weeks (Thu + Sat)
|
||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag und Samstag für die nächsten 8 Wochen:",
|
"Dein Spanischkurs ist eingetragen! Er läuft jeden Donnerstag und Samstag für die nächsten 8 Wochen:",
|
||||||
proposedChange: {
|
proposedChanges: [
|
||||||
|
{
|
||||||
|
id: "test-12",
|
||||||
action: "create",
|
action: "create",
|
||||||
event: {
|
event: {
|
||||||
title: "Spanischkurs VHS",
|
title: "Spanischkurs VHS",
|
||||||
@@ -152,6 +302,7 @@ const staticResponses: TestResponse[] = [
|
|||||||
recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8",
|
recurrenceRule: "FREQ=WEEKLY;BYDAY=TH,SA;COUNT=8",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// Response 13: Monthly overview (DYNAMIC - placeholder)
|
// Response 13: Monthly overview (DYNAMIC - placeholder)
|
||||||
{ content: "" },
|
{ content: "" },
|
||||||
@@ -177,7 +328,9 @@ async function getTestResponse(
|
|||||||
if (jensEvent) {
|
if (jensEvent) {
|
||||||
return {
|
return {
|
||||||
content: "Soll ich diesen Termin wirklich löschen?",
|
content: "Soll ich diesen Termin wirklich löschen?",
|
||||||
proposedChange: {
|
proposedChanges: [
|
||||||
|
{
|
||||||
|
id: "test-4",
|
||||||
action: "delete",
|
action: "delete",
|
||||||
eventId: jensEvent.id,
|
eventId: jensEvent.id,
|
||||||
event: {
|
event: {
|
||||||
@@ -188,6 +341,7 @@ async function getTestResponse(
|
|||||||
isRecurring: jensEvent.isRecurring,
|
isRecurring: jensEvent.isRecurring,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." };
|
return { content: "Ich konnte keinen Termin 'Meeting mit Jens' finden." };
|
||||||
@@ -210,7 +364,9 @@ async function getTestResponse(
|
|||||||
return {
|
return {
|
||||||
content:
|
content:
|
||||||
"Alles klar, ich verschiebe das Telefonat mit Mama auf Samstag um 13:00 Uhr:",
|
"Alles klar, ich verschiebe das Telefonat mit Mama auf Samstag um 13:00 Uhr:",
|
||||||
proposedChange: {
|
proposedChanges: [
|
||||||
|
{
|
||||||
|
id: "test-10",
|
||||||
action: "update",
|
action: "update",
|
||||||
eventId: mamaEvent.id,
|
eventId: mamaEvent.id,
|
||||||
updates: { startTime: newStart, endTime: newEnd },
|
updates: { startTime: newStart, endTime: newEnd },
|
||||||
@@ -222,6 +378,7 @@ async function getTestResponse(
|
|||||||
description: mamaEvent.description,
|
description: mamaEvent.description,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { content: "Ich konnte keinen Termin 'Telefonat mit Mama' finden." };
|
return { content: "Ich konnte keinen Termin 'Telefonat mit Mama' finden." };
|
||||||
@@ -290,7 +447,7 @@ export class ChatService {
|
|||||||
const answerMessage = await this.chatRepo.createMessage(conversationId, {
|
const answerMessage = await this.chatRepo.createMessage(conversationId, {
|
||||||
sender: "assistant",
|
sender: "assistant",
|
||||||
content: response.content,
|
content: response.content,
|
||||||
proposedChange: response.proposedChange,
|
proposedChanges: response.proposedChanges,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { message: answerMessage, conversationId: conversationId };
|
return { message: answerMessage, conversationId: conversationId };
|
||||||
@@ -300,15 +457,14 @@ export class ChatService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
|
proposalId: string,
|
||||||
action: EventAction,
|
action: EventAction,
|
||||||
event?: CreateEventDTO,
|
event?: CreateEventDTO,
|
||||||
eventId?: string,
|
eventId?: string,
|
||||||
updates?: UpdateEventDTO,
|
updates?: UpdateEventDTO,
|
||||||
): Promise<ChatResponse> {
|
): Promise<ChatResponse> {
|
||||||
// Update original message with respondedAction
|
// Update specific proposal with respondedAction
|
||||||
await this.chatRepo.updateMessage(messageId, {
|
await this.chatRepo.updateProposalResponse(messageId, proposalId, "confirm");
|
||||||
respondedAction: "confirm",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Perform the actual event operation
|
// Perform the actual event operation
|
||||||
let content: string;
|
let content: string;
|
||||||
@@ -343,9 +499,10 @@ export class ChatService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
|
proposalId: string,
|
||||||
): Promise<ChatResponse> {
|
): Promise<ChatResponse> {
|
||||||
// Update original message with respondedAction
|
// Update specific proposal with respondedAction
|
||||||
await this.chatRepo.updateMessage(messageId, { respondedAction: "reject" });
|
await this.chatRepo.updateProposalResponse(messageId, proposalId, "reject");
|
||||||
|
|
||||||
// Save response message to DB
|
// Save response message to DB
|
||||||
const message = await this.chatRepo.createMessage(conversationId, {
|
const message = await this.chatRepo.createMessage(conversationId, {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface AIContext {
|
|||||||
|
|
||||||
export interface AIResponse {
|
export interface AIResponse {
|
||||||
content: string;
|
content: string;
|
||||||
proposedChange?: ProposedEventChange;
|
proposedChanges?: ProposedEventChange[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AIProvider {
|
export interface AIProvider {
|
||||||
|
|||||||
@@ -26,4 +26,10 @@ export interface ChatRepository {
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
updates: UpdateMessageDTO,
|
updates: UpdateMessageDTO,
|
||||||
): Promise<ChatMessage | null>;
|
): Promise<ChatMessage | null>;
|
||||||
|
|
||||||
|
updateProposalResponse(
|
||||||
|
messageId: string,
|
||||||
|
proposalId: string,
|
||||||
|
respondedAction: "confirm" | "reject",
|
||||||
|
): Promise<ChatMessage | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,10 @@ export function expandRecurringEvents(
|
|||||||
|
|
||||||
// Recurring event: parse RRULE and expand
|
// Recurring event: parse RRULE and expand
|
||||||
try {
|
try {
|
||||||
|
// Strip RRULE: prefix if present (AI may include it)
|
||||||
|
const ruleString = event.recurrenceRule.replace(/^RRULE:/i, "");
|
||||||
const rule = rrulestr(
|
const rule = rrulestr(
|
||||||
`DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${event.recurrenceRule}`,
|
`DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${ruleString}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get occurrences within the range (using fake UTC dates)
|
// Get occurrences within the range (using fake UTC dates)
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ export type EventAction = "create" | "update" | "delete";
|
|||||||
export type RespondedAction = "confirm" | "reject";
|
export type RespondedAction = "confirm" | "reject";
|
||||||
|
|
||||||
export interface ProposedEventChange {
|
export interface ProposedEventChange {
|
||||||
|
id: string; // Unique ID for each proposal
|
||||||
action: EventAction;
|
action: EventAction;
|
||||||
eventId?: string; // Required for update/delete
|
eventId?: string; // Required for update/delete
|
||||||
event?: CreateEventDTO; // Required for create
|
event?: CreateEventDTO; // Required for create
|
||||||
updates?: UpdateEventDTO; // Required for update
|
updates?: UpdateEventDTO; // Required for update
|
||||||
|
respondedAction?: RespondedAction; // User's response to this specific proposal
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
@@ -18,8 +20,7 @@ export interface ChatMessage {
|
|||||||
conversationId: string;
|
conversationId: string;
|
||||||
sender: MessageSender;
|
sender: MessageSender;
|
||||||
content: string;
|
content: string;
|
||||||
proposedChange?: ProposedEventChange;
|
proposedChanges?: ProposedEventChange[]; // Array of event proposals
|
||||||
respondedAction?: RespondedAction;
|
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export interface SendMessageDTO {
|
|||||||
export interface CreateMessageDTO {
|
export interface CreateMessageDTO {
|
||||||
sender: MessageSender;
|
sender: MessageSender;
|
||||||
content: string;
|
content: string;
|
||||||
proposedChange?: ProposedEventChange;
|
proposedChanges?: ProposedEventChange[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetMessagesOptions {
|
export interface GetMessagesOptions {
|
||||||
@@ -47,6 +48,7 @@ export interface GetMessagesOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMessageDTO {
|
export interface UpdateMessageDTO {
|
||||||
|
proposalId?: string; // Identifies which proposal to update
|
||||||
respondedAction?: RespondedAction;
|
respondedAction?: RespondedAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user