mcp
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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<AssistantChatResponse | null> {
|
||||
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[] {
|
||||
|
||||
Reference in New Issue
Block a user