From 35613eddb6ac298c3deef690eaf82564c4e90e44 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 23 Jun 2026 15:46:57 +0200 Subject: [PATCH] mcp --- .../src/assistant/assistant.service.spec.ts | 125 ++++++++++++++- .../src/assistant/assistant.service.ts | 147 ++++++++++++++++-- 2 files changed, 255 insertions(+), 17 deletions(-) diff --git a/listify-api/src/assistant/assistant.service.spec.ts b/listify-api/src/assistant/assistant.service.spec.ts index 7b92c21..1d48f03 100644 --- a/listify-api/src/assistant/assistant.service.spec.ts +++ b/listify-api/src/assistant/assistant.service.spec.ts @@ -2,6 +2,11 @@ import { ServiceUnavailableException } from '@nestjs/common'; import { AssistantService } from './assistant.service'; describe('AssistantService', () => { + const connectorSystemMessage = [ + 'Benutze fuer Listify-Daten immer den listify Connector.', + 'Wenn der User eine Liste anlegen will, musst du create_list verwenden und darfst keinen JSON-Entwurf als Antwort ausgeben.', + 'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.', + ].join(' '); const originalFetch = global.fetch; const originalApiKey = process.env.MISTRAL_API_KEY; const originalAgentId = process.env.MISTRAL_AGENT_ID; @@ -65,7 +70,7 @@ describe('AssistantService', () => { const result = await service.chat('user-1', { messages: [ { role: 'assistant', content: 'Hallo' }, - { role: 'user', content: 'Erstelle eine Liste' }, + { role: 'user', content: 'Hallo' }, ], }); @@ -81,8 +86,8 @@ describe('AssistantService', () => { agent_id: 'agent-listify', messages: [ { role: 'assistant', content: 'Hallo' }, - { role: 'user', content: 'Erstelle eine Liste' }, - { role: 'system', content: 'benutze immer den listify connector' }, + { role: 'user', content: 'Hallo' }, + { role: 'system', content: connectorSystemMessage }, ], tools: [ { @@ -281,7 +286,7 @@ describe('AssistantService', () => { expect(contextContent).not.toContain('checkedByUserId'); expect(payload.messages.at(-1)).toEqual({ role: 'system', - content: 'benutze immer den listify connector', + content: connectorSystemMessage, }); }); @@ -480,6 +485,118 @@ describe('AssistantService', () => { ); }); + it('extracts created list actions from raw tool JSON content', async () => { + const createdList = { + id: 'list-1', + ownerId: 'user-1', + accessRole: 'owner', + name: 'Einkauf', + kind: 'shopping', + items: [ + { + id: 'item-1', + title: 'Milch', + 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', + }; + const providerResponse = { + choices: [ + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call-1', + type: 'function', + function: { + name: 'listify_create_list', + arguments: '{"name": "Einkauf"}', + }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'text', + text: JSON.stringify({ list: createdList }), + }, + ], + tool_call_id: 'call-1', + }, + { + role: 'assistant', + content: JSON.stringify({ list: createdList }), + }, + ], + }, + ], + }; + mockMistralResponse(providerResponse); + + const result = await service.chat('user-1', { + messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }], + }); + + expect(result).toEqual({ + message: { + role: 'assistant', + content: 'Ich habe die Liste **Einkauf** angelegt.', + }, + actions: [ + { + type: 'list.created', + listId: 'list-1', + list: createdList, + }, + ], + }); + expect(listRealtimeService.publishSnapshot).toHaveBeenCalledWith( + 'user-1', + createdList, + ); + }); + + it('returns a clear message when list creation did not use the connector', async () => { + mockMistralResponse({ + choices: [ + { + message: { + content: JSON.stringify({ + name: 'Einkauf', + items: [{ title: 'Milch' }], + }), + }, + }, + ], + }); + + const result = await service.chat('user-1', { + messages: [{ role: 'user', content: 'Erstelle eine Einkaufsliste' }], + }); + + expect(result).toEqual({ + message: { + role: 'assistant', + content: + 'Ich konnte die Liste nicht anlegen, weil der Listify-Connector keine Liste erstellt hat.', + }, + actions: [], + }); + expect(listRealtimeService.publishSnapshot).not.toHaveBeenCalled(); + }); + it('fails clearly when the api key is missing', async () => { delete process.env.MISTRAL_API_KEY; diff --git a/listify-api/src/assistant/assistant.service.ts b/listify-api/src/assistant/assistant.service.ts index af151c0..34430b3 100644 --- a/listify-api/src/assistant/assistant.service.ts +++ b/listify-api/src/assistant/assistant.service.ts @@ -120,13 +120,29 @@ export class AssistantService { } const response = await this.callMistralAgent(userId, messages, context); - const content = this.extractAssistantContent(response); const actions = this.extractActions(response); + const content = + this.extractAssistantContent(response) ?? this.createActionContent(actions); + const latestUserMessage = this.latestUserMessage(messages); actions.forEach((action) => { this.listRealtimeService.publishSnapshot(userId, action.list); }); + if ( + this.isListCreationRequest(latestUserMessage?.content ?? '') && + !actions.some((action) => action.type === 'list.created') + ) { + return { + message: { + role: 'assistant', + content: + 'Ich konnte die Liste nicht anlegen, weil der Listify-Connector keine Liste erstellt hat.', + }, + actions: [], + }; + } + if (!content) { throw new ServiceUnavailableException('Mistral response was empty.'); } @@ -144,9 +160,7 @@ export class AssistantService { userId: string, messages: AssistantChatMessage[], ): Promise { - const latestUserMessage = [...messages] - .reverse() - .find((message) => message.role === 'user'); + const latestUserMessage = this.latestUserMessage(messages); const content = latestUserMessage?.content.toLowerCase() ?? ''; if (!this.isListReadRequest(content)) { @@ -195,9 +209,7 @@ export class AssistantService { return null; } - const latestUserMessage = [...messages] - .reverse() - .find((message) => message.role === 'user'); + const latestUserMessage = this.latestUserMessage(messages); const itemTitle = this.extractItemTitleToAdd(latestUserMessage?.content ?? ''); if (!itemTitle) { @@ -300,6 +312,29 @@ export class AssistantService { return asksToRead && (asksForOpenLists || asksForOwnLists); } + private isListCreationRequest(content: string): boolean { + const normalized = this.normalizeIntentText(content); + const mentionsList = + /\b(list|liste|listen|packliste|einkaufsliste|todo|aufgabenliste)\b/.test( + normalized, + ); + const asksToCreate = + /\b(erstell|erstelle|erstellt|erzeugen|generier|generiere|mach|mache|bau|baue)\w*\b/.test( + normalized, + ) || /\b(?:leg|lege)\s+.*\b(?:an|neu)\b/.test(normalized); + + return mentionsList && asksToCreate; + } + + private normalizeIntentText(content: string): string { + return content + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + } + private isCompletedList(list: UserList): boolean { return ( list.items.length > 0 && list.items.every((item) => item.checked === true) @@ -364,7 +399,14 @@ export class AssistantService { messages: [ ...messages, ...(contextMessage ? [contextMessage] : []), - { role: 'system', content: 'benutze immer den listify connector' }, + { + role: 'system', + content: [ + 'Benutze fuer Listify-Daten immer den listify Connector.', + 'Wenn der User eine Liste anlegen will, musst du create_list verwenden und darfst keinen JSON-Entwurf als Antwort ausgeben.', + 'Wenn der Connector keine Liste erstellt, sage kurz, dass die Liste nicht angelegt werden konnte.', + ].join(' '), + }, ], tools: [ { @@ -445,7 +487,7 @@ export class AssistantService { const response = responsePayload as MistralAgentCompletionResponse; const directContent = response.choices?.[0]?.message?.content?.trim(); - if (directContent) { + if (directContent && !this.looksLikeJson(directContent)) { return directContent; } @@ -455,10 +497,9 @@ export class AssistantService { .reverse(); for (const message of assistantMessages) { - const content = - typeof message.content === 'string' ? message.content.trim() : ''; + const content = this.contentToText(message.content).trim(); - if (content) { + if (content && !this.looksLikeJson(content)) { return content; } } @@ -492,7 +533,9 @@ export class AssistantService { } const structuredContent = message.metadata?.mcp_meta?.structuredContent; - const list = this.listFromStructuredContent(structuredContent); + const list = + this.listFromStructuredContent(structuredContent) ?? + this.listFromToolContent(message.content); if (!list) { continue; @@ -558,6 +601,78 @@ export class AssistantService { return candidate as UserList; } + private listFromToolContent(value: unknown): UserList | null { + for (const text of this.contentTextParts(value)) { + try { + const parsed = JSON.parse(text) as unknown; + const list = this.listFromStructuredContent(parsed); + + if (list) { + return list; + } + } catch { + continue; + } + } + + return null; + } + + private contentToText(value: unknown): string { + return this.contentTextParts(value).join('\n'); + } + + private contentTextParts(value: unknown): string[] { + if (typeof value === 'string') { + return [value]; + } + + if (!Array.isArray(value)) { + return []; + } + + return value + .map((part) => { + if (typeof part === 'string') { + return part; + } + + if (!part || typeof part !== 'object' || Array.isArray(part)) { + return null; + } + + const candidate = part as { text?: unknown }; + + return typeof candidate.text === 'string' ? candidate.text : null; + }) + .filter((part): part is string => part !== null); + } + + private looksLikeJson(content: string): boolean { + const trimmed = content.trim(); + + return ( + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ); + } + + private createActionContent(actions: AssistantAction[]): string | null { + const createdList = actions.find((action) => action.type === 'list.created'); + + if (createdList) { + return `Ich habe die Liste **${createdList.list.name}** angelegt.`; + } + + const addedItem = actions.find((action) => action.type === 'list.item_added'); + + if (addedItem) { + return `Ich habe **${addedItem.itemTitle}** zu **${addedItem.list.name}** hinzugefuegt.`; + } + + return null; + } + private lastItemTitle(list: UserList): string { return list.items.at(-1)?.title ?? 'Eintrag'; } @@ -606,6 +721,12 @@ export class AssistantService { ); } + private latestUserMessage( + messages: AssistantChatMessage[], + ): AssistantChatMessage | undefined { + return [...messages].reverse().find((message) => message.role === 'user'); + } + private normalizeMessages( messages: AssistantChatMessage[] | undefined, ): AssistantChatMessage[] {