From f86416e8bc1d62227ce1dc479e592e352520f332 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 18 Jun 2026 19:02:49 +0200 Subject: [PATCH] mcp --- .../src/assistant/assistant.service.spec.ts | 82 ++++++++++++- .../src/assistant/assistant.service.ts | 112 ++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/listify-api/src/assistant/assistant.service.spec.ts b/listify-api/src/assistant/assistant.service.spec.ts index 899311d..7b92c21 100644 --- a/listify-api/src/assistant/assistant.service.spec.ts +++ b/listify-api/src/assistant/assistant.service.spec.ts @@ -14,6 +14,7 @@ describe('AssistantService', () => { }; let listsService: { listLists: jest.Mock; + addItem: jest.Mock; }; let service: AssistantService; @@ -30,6 +31,7 @@ describe('AssistantService', () => { }; listsService = { listLists: jest.fn(), + addItem: jest.fn(), }; service = new AssistantService( chatLogsRepository as never, @@ -210,7 +212,7 @@ describe('AssistantService', () => { mockMistralResponse(providerResponse); await service.chat('user-1', { - messages: [{ role: 'user', content: 'Fuege hier Brot hinzu' }], + messages: [{ role: 'user', content: 'Was ist hier wichtig?' }], context: { page: 'list_detail', route: '/lists/list-1', @@ -283,6 +285,84 @@ describe('AssistantService', () => { }); }); + it('adds list items locally when the user writes onto the current list', async () => { + const updatedList = { + id: 'list-1', + ownerId: 'user-1', + accessRole: 'owner', + name: 'Einkauf', + kind: 'shopping', + items: [ + { + id: 'item-1', + title: 'Brot', + required: true, + checked: false, + position: 0, + createdAt: '2026-06-12T00:00:00.000Z', + updatedAt: '2026-06-12T00:00:00.000Z', + }, + ], + collaborators: [], + createdAt: '2026-06-12T00:00:00.000Z', + updatedAt: '2026-06-12T00:00:00.000Z', + }; + listsService.addItem.mockResolvedValue(updatedList); + + const result = await service.chat('user-1', { + messages: [{ role: 'user', content: 'Fuege hier Brot hinzu' }], + context: { + page: 'list_detail', + route: '/lists/list-1', + list: { + id: 'list-1', + ownerId: 'user-1', + accessRole: 'owner', + name: 'Einkauf', + kind: 'shopping', + items: [], + collaborators: [], + createdAt: '2026-06-12T00:00:00.000Z', + updatedAt: '2026-06-12T00:00:00.000Z', + }, + }, + }); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(listsService.addItem).toHaveBeenCalledWith('user-1', 'list-1', { + title: 'Brot', + }); + expect(result).toEqual({ + message: { + role: 'assistant', + content: 'Ich habe **Brot** zu **Einkauf** hinzugefuegt.', + }, + actions: [ + { + type: 'list.item_added', + listId: 'list-1', + itemTitle: 'Brot', + list: updatedList, + }, + ], + }); + expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith( + 'user-1', + updatedList, + ); + expect(chatLogsRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'listify', + endpoint: 'local:addItem', + requestPayload: expect.objectContaining({ + intent: 'list.add_item', + listId: 'list-1', + itemTitle: 'Brot', + }), + }), + ); + }); + it('logs full failed provider responses before throwing', async () => { const providerResponse = { message: 'connector failed', diff --git a/listify-api/src/assistant/assistant.service.ts b/listify-api/src/assistant/assistant.service.ts index 178a4e5..af151c0 100644 --- a/listify-api/src/assistant/assistant.service.ts +++ b/listify-api/src/assistant/assistant.service.ts @@ -109,6 +109,16 @@ export class AssistantService { return localResponse; } + const localMutationResponse = await this.tryHandleLocalListMutation( + userId, + messages, + context, + ); + + if (localMutationResponse) { + return localMutationResponse; + } + const response = await this.callMistralAgent(userId, messages, context); const content = this.extractAssistantContent(response); const actions = this.extractActions(response); @@ -176,6 +186,108 @@ export class AssistantService { }; } + private async tryHandleLocalListMutation( + userId: string, + messages: AssistantChatMessage[], + context: NormalizedAssistantPageContext | null, + ): Promise { + if (context?.page !== 'list_detail') { + return null; + } + + const latestUserMessage = [...messages] + .reverse() + .find((message) => message.role === 'user'); + const itemTitle = this.extractItemTitleToAdd(latestUserMessage?.content ?? ''); + + if (!itemTitle) { + return null; + } + + const startedAt = Date.now(); + const list = await this.listsService.addItem(userId, context.list.id, { + title: itemTitle, + }); + const assistantContent = `Ich habe **${itemTitle}** zu **${list.name}** hinzugefuegt.`; + const action: AssistantAction = { + type: 'list.item_added', + listId: list.id, + itemTitle, + list, + }; + + this.listRealtimeService.publishSnapshot(userId, list); + + await this.recordChatLog({ + userId, + provider: 'listify', + endpoint: 'local:addItem', + agentId: null, + requestPayload: { + intent: 'list.add_item', + latestUserMessage: latestUserMessage?.content, + listId: context.list.id, + itemTitle, + }, + responsePayload: { list }, + statusCode: 200, + durationMs: Date.now() - startedAt, + assistantContent, + errorMessage: null, + }); + + return { + message: { + role: 'assistant', + content: assistantContent, + }, + actions: [action], + }; + } + + private extractItemTitleToAdd(content: string): string | null { + const compacted = content.replace(/\s+/g, ' ').trim(); + + if (!compacted) { + return null; + } + + const patterns = [ + /\b(?:fuege|füge)\s+(.+?)\s+(?:hinzu|dazu|drauf|auf die liste|auf diese liste)\b/i, + /\b(?:schreib|schreibe|notier|notiere|pack|setze|setz)\s+(.+?)(?:\s+(?:drauf|dazu|hinzu|auf die liste|auf diese liste))?$/i, + /\b(.+?)\s+(?:draufschreiben|aufschreiben|hinzufuegen|hinzufügen)\b/i, + ]; + + for (const pattern of patterns) { + const match = compacted.match(pattern); + const title = this.cleanExtractedItemTitle(match?.[1]); + + if (title) { + return title; + } + } + + return null; + } + + private cleanExtractedItemTitle(value: string | undefined): string | null { + if (!value) { + return null; + } + + const cleaned = value + .replace(/^(?:bitte|noch|auch|mal|hier)\s+/i, '') + .replace(/\s+(?:bitte|noch|auch|mal)$/i, '') + .replace(/^["'`]+|["'`.,!?]+$/g, '') + .trim(); + + if (!cleaned || cleaned.length > 220) { + return null; + } + + return cleaned; + } + private isListReadRequest(content: string): boolean { const asksForLists = /\b(listen|liste)\b/.test(content); const asksToRead =